diff --git a/.memory/aiden-cli-standalone.md b/.memory/aiden-cli-standalone.md index 2621503ec..b21ccc307 100644 --- a/.memory/aiden-cli-standalone.md +++ b/.memory/aiden-cli-standalone.md @@ -1,7 +1,11 @@ # Aiden CLI standalone (PR #121, target 0.50.0) -- `packages/cli` is its own npm package. It is not included in the desktop - electron-builder `files` (`build/**` only), so desktop packaging is unaffected. +## Lease ownership (issues #405 and #392, 2026-10-15) + +CLI `acquireLease` holds an OS `flock` on a permanent `.lease.lock` file until release. A short native helper locks a duplicate of the parent's descriptor, then exits; the parent keeps the lock through async work, and process death releases it. A raw read of the guard cannot cancel the lock. The former `.lock/owner.json` is diagnostic metadata, published and removed only under the guard; malformed values including JSON `null` are stale. Serve recovery, including foreground startup, and stop cleanup acquire that same guard, recheck the owner, and remove stale metadata and socket only while holding it. New metadata carries `protocol: "flock-v1"`, so a recycled live PID cannot wedge recovery after the guard is free; legacy live metadata remains protected unless its PID is verifiably foreign. The daemon child removes a stale socket after it acquires the serve lease. Never unlink, rename, or copy over guard files. Stop older CLI processes and daemons before upgrading a shared agent directory; mixed-version writers do not share this protocol. + +- `packages/cli` is its own npm package. The shared lease helper is copied into + `dist/app/native/` for CLI bundles and `Helpers/` by desktop electron-builder. - Several desktop services are split into portable `*-core.ts` modules (schedule-store, subagent-supervisor, vision-analysis-tool, local-speech-downloads, aiden-remote-speech, subagent-mcp-mutation-host). When main changes one of diff --git a/.memory/bot-durable-runtime.md b/.memory/bot-durable-runtime.md index 89ae0dcf2..73f783dd5 100644 --- a/.memory/bot-durable-runtime.md +++ b/.memory/bot-durable-runtime.md @@ -4,7 +4,7 @@ Plan: `docs/superpowers/plans/2026-10-07-bots-rework.md`. Spec: `docs/superpower ## Layout - `main/services/bot-runtime/` - - `profile-lock.ts`: `/bots/runtime.lock` (`{pid, startedAt, token}`), published with `writeFileAtomic({exclusive})`. Live pid (or this process's own token) refuses; a dead pid or torn file is reclaimed by moving it aside first. + - `profile-lock.ts`: `/bots/runtime.lease.lock` is a permanent OS `flock` guard held through a parent-owned descriptor for the host lifetime. The `runtime.lock` JSON record is diagnostic; stale new-protocol metadata is replaced only while the guard is held. A crashed process releases the OS lock without path reclamation, and a raw guard read cannot cancel it. Host opens serialize behind closes, and failed teardown entries cannot be reused. Shutdown closes mutation admission, drains in-flight operations and harnesses, then releases the guard; an old host cannot mutate a successor's data. Never delete or replace the guard. Stop old Bot hosts before upgrading a shared profile because old binaries do not honor it. - `harness-host.ts`: one Pi Durable harness per Bot at `/bots//session.sqlite`. `` is `botDirectoryName(botId)`: characters outside `[A-Za-z0-9_-]` become `~xx` UTF-8 bytes (`bot:1` → `bot~3a1`), because Bot ids contain `:`. Lazy open, 10-minute idle close (only with no live task or submission), corrupt DB (SQLite errcode 26/11) moved to `session.sqlite.corrupt-` and a fresh root opened, `destroy` = abort (bounded 10 s) → close → `rm -rf`, deleted ids refuse reopen. - `tool-adapter.ts`: pi-agent-core `AgentTool` → Pi Durable tool (TypeBox args validated by the harness, chord signal → `AbortSignal`, `onUpdate` snapshots → appended `api.output`, details → `api.details`). - `bot-extension.ts`: `aiden-bot` extension. Sections `aiden-base`, `aiden-persona`, `aiden-authority` (untagged, in that order). `refresh({requestId})` reloads Bot/sections/tools and reinstalls the extension; call it before every submit/resume. `beforeTool` checks policy; approvals persist `{waitId, toolCallId, summary}` in the tool task memo `aiden.approval`. diff --git a/.memory/lease-reclaim-fencing.md b/.memory/lease-reclaim-fencing.md new file mode 100644 index 000000000..698b193a5 --- /dev/null +++ b/.memory/lease-reclaim-fencing.md @@ -0,0 +1,5 @@ +# Lease reclaim fencing + +PR #499 protects CLI leases and Bot profiles with permanent native `flock` guards held through inherited descriptors. Owner JSON is diagnostic; a free guard permits stale-owner recovery, including recycled PIDs. Bot shutdown closes mutation admission, drains admitted work, and releases the profile guard last. Keep the root test-chain union and Aiden Remote revision aligned when merging main. + +The bounded vendor-command descendant cleanup test is adjacent CI coverage, not part of the lease implementation. Its leader deliberately delays spawning a descendant that retains stdout and ignores SIGTERM. The descendant writes its PID only after installing that handler. The test holds the parent command timer with Node's test clock until this readiness file exists, then advances the original 250 ms timeout and verifies prompt rejection and descendant exit. This separates fixture startup from timeout cleanup without relaxing the production timeout, the test deadline, or `--fail-on-flaky-tests`. The earlier CI `ENOENT` and its run link remain in the PR description as a real flaky symptom. diff --git a/main/services/bot-runtime/harness-host.test.ts b/main/services/bot-runtime/harness-host.test.ts index a4cfab2cd..c93570f5a 100644 --- a/main/services/bot-runtime/harness-host.test.ts +++ b/main/services/bot-runtime/harness-host.test.ts @@ -89,6 +89,65 @@ test("(a2) an idle harness closes by itself and reopens mid-conversation", async } }); +test("open waits for an overlapping close and returns a usable new session", async () => { + const profile = tempProfile(); + const host = await hostFor(profile, createFauxModels()); + const first = await host.open("bot-overlap"); + const originalClose = first.harness.close.bind(first.harness); + let closeStarted!: () => void; + let finishClose!: () => void; + const entered = new Promise((resolve) => { closeStarted = resolve; }); + const gate = new Promise((resolve) => { finishClose = resolve; }); + first.harness.close = async (context) => { + closeStarted(); + await gate; + return originalClose(context); + }; + const closing = host.close("bot-overlap"); + try { + await entered; + let reopened = false; + const reopening = host.open("bot-overlap").then((result) => { + reopened = true; + return result; + }); + await Promise.resolve(); + assert.equal(reopened, false, "open must wait for the old session to close"); + finishClose(); + await closing; + const second = await reopening; + assert.notEqual(second.harness, first.harness); + assert.equal(second.conversation.id, first.conversation.id); + await second.conversation.context(ctx); + } finally { + finishClose(); + await closing.catch(() => undefined); + await host.shutdown(); + } +}); + +test("failed close retains the profile guard and open retries teardown", async () => { + const profile = tempProfile(); + const host = await hostFor(profile, createFauxModels()); + const first = await host.open("bot-close-retry"); + const originalClose = first.harness.close.bind(first.harness); + let attempts = 0; + first.harness.close = async (context) => { + if (++attempts === 1) throw new Error("teardown failed"); + return originalClose(context); + }; + try { + await assert.rejects(host.close("bot-close-retry"), /teardown failed/); + const contender = spawnHarnessChild("lock", [path.join(profile, "bots")]); + try { + assert.equal(JSON.parse(await contender.waitFor("LOCK")).ok, false); + } finally { await contender.kill(); } + const reopened = await host.open("bot-close-retry"); + assert.notEqual(reopened.harness, first.harness); + await reopened.conversation.context(ctx); + } finally { await host.shutdown(); } +}); + test("(b) after SIGKILL mid-stream the scan reports the Bot without calling the provider", async () => { const profile = tempProfile(); const child = spawnHarnessChild("stream", [profile, "bot:killed", "req-1"]); @@ -162,7 +221,7 @@ test("(d) sweepOrphans removes directories with no known Bot", async () => { writeFileSync(path.join(profile, "bots", "bot-orphan", BOT_SESSION_FILE), ""); const removed = await host.sweepOrphans(new Set(["bot:known"])); assert.deepEqual(removed, ["bot-orphan"]); - assert.deepEqual(readdirSync(path.join(profile, "bots")).sort(), ["bot~3aknown", "runtime.lock"]); + assert.deepEqual(readdirSync(path.join(profile, "bots")).sort(), ["bot~3aknown", "runtime.lease.lock", "runtime.lock"]); } finally { await host.shutdown(); } @@ -195,6 +254,54 @@ test("(e2) the profile is available again after shutdown", async () => { } }); +test("a shut down host cannot delete a replacement host's Bot", async () => { + const profile = tempProfile(); + const oldHost = await hostFor(profile, createFauxModels()); + await oldHost.shutdown(); + const replacement = await hostFor(profile, createFauxModels()); + try { + await replacement.open("bot-replacement"); + const session = path.join(profile, "bots", "bot-replacement", BOT_SESSION_FILE); + assert.equal(existsSync(session), true); + await assert.rejects(oldHost.destroy("bot-replacement"), /shut down/); + await assert.rejects(oldHost.sweepOrphans(new Set()), /shut down/); + await assert.rejects(oldHost.close("bot-replacement"), /shut down/); + assert.throws(() => oldHost.touch("bot-replacement"), /shut down/); + assert.equal(existsSync(session), true); + assert.equal(replacement.isOpen("bot-replacement"), true); + } finally { await replacement.shutdown(); } +}); + +test("shutdown holds the profile guard until an admitted inspection finishes", async () => { + const profile = tempProfile(); + const host = await hostFor(profile, createFauxModels()); + const entry = await host.open("bot-inspected"); + const inspect = entry.harness.inspect.bind(entry.harness); + let started!: () => void; + let resume!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + const gate = new Promise((resolve) => { resume = resolve; }); + entry.harness.inspect = async (context) => { + started(); + await gate; + return inspect(context); + }; + const pending = host.inspect("bot-inspected"); + await entered; + const shutdown = host.shutdown(); + const contender = spawnHarnessChild("lock", [path.join(profile, "bots")]); + try { + assert.equal(JSON.parse(await contender.waitFor("LOCK")).ok, false); + } finally { + resume(); + await contender.kill(); + } + await pending; + await shutdown; + const replacement = await hostFor(profile, createFauxModels()); + await replacement.shutdown(); +}); + test("(f) a corrupt session file is moved aside and a fresh conversation opens", async () => { const profile = tempProfile(); const dir = path.join(profile, "bots", "bot-corrupt"); diff --git a/main/services/bot-runtime/harness-host.ts b/main/services/bot-runtime/harness-host.ts index 88a615b85..8c2932a4b 100644 --- a/main/services/bot-runtime/harness-host.ts +++ b/main/services/bot-runtime/harness-host.ts @@ -24,7 +24,7 @@ import { type Registry, } from "@earendil-works/pi-durable"; import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; -import { acquireBotProfileLock, BOT_PROFILE_LOCK_FILE } from "./profile-lock.js"; +import { acquireBotProfileLock, BOT_PROFILE_LOCK_FILE, BOT_PROFILE_GUARD_FILE } from "./profile-lock.js"; export const BOT_SESSION_FILE = "session.sqlite"; export const DEFAULT_BOT_IDLE_CLOSE_MS = 600_000; @@ -184,12 +184,23 @@ export async function createBotHarnessHost( const ctx: Context = BACKGROUND_CONTEXT; const idleCloseMs = opts.idleCloseMs ?? DEFAULT_BOT_IDLE_CLOSE_MS; const now = opts.now ?? Date.now; - const opening = new Map>(); const open = new Map(); + /** Keep failed teardowns out of the reusable-entry path. */ + const closing = new Set(); const deleted = new Set(); /** Per-Bot serialization of open/close/destroy. */ const lifecycle = new Map>(); + const admittedOperations = new Set>(); let shutDown = false; + let shutdownPromise: Promise | null = null; + + function admit(action: () => Promise): Promise { + if (shutDown) return Promise.reject(new BotHarnessHostClosedError()); + const operation = action(); + admittedOperations.add(operation); + void operation.finally(() => admittedOperations.delete(operation)).catch(() => undefined); + return operation; + } const botDir = (botId: string) => path.join(botsDir, botDirectoryName(botId)); const sessionFile = (botId: string) => path.join(botDir(botId), BOT_SESSION_FILE); @@ -248,12 +259,13 @@ export async function createBotHarnessHost( if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); entry.idleTimer = setTimeout(() => { entry.idleTimer = null; - void closeIfIdle(botId, entry); + void admit(() => closeIfIdle(botId, entry)).catch(() => undefined); }, idleCloseMs); entry.idleTimer.unref?.(); } async function closeIfIdle(botId: string, entry: OpenEntry): Promise { + if (shutDown) return; if (open.get(botId) !== entry) return; try { const inspection = await entry.harness.inspect(ctx); @@ -264,25 +276,26 @@ export async function createBotHarnessHost( } catch { // A harness that cannot be inspected is closed below. } - await host.close(botId).catch(() => undefined); + if (!shutDown) { + await serialize(botId, () => open.get(botId) === entry ? closeEntry(botId) : Promise.resolve()).catch(() => undefined); + } } async function ensureOpen(botId: string): Promise { assertBotId(botId); if (shutDown) throw new BotHarnessHostClosedError(); if (deleted.has(botId)) throw new BotDeletedError(botId); - const existing = open.get(botId); - if (existing !== undefined) { - scheduleIdle(botId, existing); - return existing; - } - const pending = opening.get(botId); - if (pending !== undefined) return pending; - const promise = serialize(botId, async () => { + return serialize(botId, async () => { if (shutDown) throw new BotHarnessHostClosedError(); if (deleted.has(botId)) throw new BotDeletedError(botId); const already = open.get(botId); - if (already !== undefined) return already; + if (already !== undefined) { + if (closing.has(botId)) await closeEntry(botId); + else { + scheduleIdle(botId, already); + return already; + } + } await fs.mkdir(botDir(botId), { recursive: true, mode: 0o700 }); let opened: { harness: Harness; conversation: Conversation }; let notice: string | undefined; @@ -295,6 +308,7 @@ export async function createBotHarnessHost( } const entry: OpenEntry = { ...opened, idleTimer: null, ...(notice === undefined ? {} : { pendingNotice: notice }) }; open.set(botId, entry); + closing.delete(botId); scheduleIdle(botId, entry); // pi-durable 1.2 closes a Session for good when any Storage call fails; // drop it so the next open reopens and recovers from what was committed. @@ -306,20 +320,19 @@ export async function createBotHarnessHost( }); return entry; }); - opening.set(botId, promise); - try { - return await promise; - } finally { - if (opening.get(botId) === promise) opening.delete(botId); - } } async function closeEntry(botId: string): Promise { const entry = open.get(botId); - if (entry === undefined) return; - open.delete(botId); + if (entry === undefined) { + closing.delete(botId); + return; + } + closing.add(botId); if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); await entry.harness.close(ctx); + if (open.get(botId) === entry) open.delete(botId); + closing.delete(botId); } async function listBotDirectories(): Promise { @@ -332,7 +345,7 @@ export async function createBotHarnessHost( } const result: string[] = []; for (const name of names) { - if (name === BOT_PROFILE_LOCK_FILE || name.startsWith(".")) continue; + if (name === BOT_PROFILE_LOCK_FILE || name === BOT_PROFILE_GUARD_FILE || name.startsWith(".")) continue; const stat = await fs.lstat(path.join(botsDir, name)).catch(() => null); if (stat?.isDirectory()) result.push(name); } @@ -340,7 +353,7 @@ export async function createBotHarnessHost( } const host: BotHarnessHost = { - async open(botId) { + open(botId) { return admit(async () => { const entry = await ensureOpen(botId); const notice = entry.pendingNotice; delete entry.pendingNotice; @@ -349,19 +362,20 @@ export async function createBotHarnessHost( conversation: entry.conversation, ...(notice === undefined ? {} : { recoveredCorruptFile: notice }), }; - }, + }); }, touch(botId) { + if (shutDown) throw new BotHarnessHostClosedError(); const entry = open.get(botId); if (entry !== undefined) scheduleIdle(botId, entry); }, - async inspect(botId) { + inspect(botId) { return admit(async () => { const entry = await ensureOpen(botId); return entry.harness.inspect(ctx); - }, + }); }, - async interruptedBots() { + interruptedBots() { return admit(async () => { const result: BotInterruption[] = []; for (const name of await listBotDirectories()) { const botId = botIdFromDirectoryName(name); @@ -378,21 +392,19 @@ export async function createBotHarnessHost( result.push({ botId, submissionId: String(unfinished.id) }); } else if (inspection.tasks.length === 0 && inspection.submissions.length === 0) { // Nothing to recover: do not keep this Bot's database open. - await host.close(botId); + await serialize(botId, () => closeEntry(botId)); } } return result; - }, + }); }, close(botId) { - return serialize(botId, () => closeEntry(botId)); + return admit(() => serialize(botId, () => closeEntry(botId))); }, - async destroy(botId) { + destroy(botId) { return admit(async () => { assertBotId(botId); deleted.add(botId); - const pending = opening.get(botId); - if (pending !== undefined) await pending.catch(() => undefined); await serialize(botId, async () => { const entry = open.get(botId); if (entry !== undefined) { @@ -411,9 +423,9 @@ export async function createBotHarnessHost( } await fs.rm(botDir(botId), { recursive: true, force: true }); }); - }, + }); }, - async sweepOrphans(knownBotIds) { + sweepOrphans(knownBotIds) { return admit(async () => { const removed: string[] = []; for (const name of await listBotDirectories()) { const botId = botIdFromDirectoryName(name); @@ -425,18 +437,25 @@ export async function createBotHarnessHost( removed.push(name); } return removed; - }, + }); }, isOpen(botId) { return open.has(botId); }, async shutdown() { - if (shutDown) return; - shutDown = true; - await Promise.allSettled([...opening.values()]); - await Promise.allSettled([...open.keys()].map((botId) => serialize(botId, () => closeEntry(botId)))); - await lock.release(); + if (shutdownPromise === null) { + shutDown = true; + shutdownPromise = (async () => { + await Promise.allSettled([...admittedOperations]); + const closes = await Promise.allSettled([...open.keys()].map((botId) => serialize(botId, () => closeEntry(botId)))); + const failed = closes.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failed) throw failed.reason; + await Promise.all([...lifecycle.values()]); + await lock.release(); + })(); + } + return shutdownPromise; }, }; return host; diff --git a/main/services/bot-runtime/profile-lock.test.ts b/main/services/bot-runtime/profile-lock.test.ts index 5e8af9e4a..b78339f35 100644 --- a/main/services/bot-runtime/profile-lock.test.ts +++ b/main/services/bot-runtime/profile-lock.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, test } from "node:test"; -import { acquireBotProfileLock, BOT_PROFILE_LOCK_FILE } from "./profile-lock.js"; +import { acquireBotProfileLock, BOT_PROFILE_GUARD_FILE, BOT_PROFILE_LOCK_FILE } from "./profile-lock.js"; import { spawnHarnessChild } from "./test-support/child.js"; const roots: string[] = []; @@ -39,6 +39,20 @@ test("a second process is refused while the holder is alive", async () => { } }); +test("a raw read and close of the guard cannot release a live owner's lock", async () => { + const dir = tempBotsDir(); + const owner = await acquireBotProfileLock(dir); + assert.equal(owner.ok, true); + const child = spawnHarnessChild("lock", [dir]); + try { + readFileSync(path.join(dir, BOT_PROFILE_GUARD_FILE)); + assert.equal(JSON.parse(await child.waitFor("LOCK")).ok, false); + } finally { + await child.kill(); + if (owner.ok) await owner.release(); + } +}); + test("this process cannot take the lock twice", async () => { const dir = tempBotsDir(); const first = await acquireBotProfileLock(dir); @@ -63,6 +77,70 @@ test("a lock left by a dead process is reclaimed", async () => { if (lock.ok) await lock.release(); }); +test("three ready processes competing after SIGKILL never overlap", async () => { + const dir = tempBotsDir(); + const stale = spawnHarnessChild("lock", [dir]); + assert.deepEqual(JSON.parse(await stale.waitFor("LOCK")), { ok: true }); + await stale.kill(); + const gate = path.join(path.dirname(dir), "go"); + const contenders = Array.from({ length: 3 }, () => spawnHarnessChild("lock-gated", [dir, gate])); + try { + await Promise.all(contenders.map((child) => child.waitFor("READY"))); + writeFileSync(gate, "go"); + const results = await Promise.all(contenders.map(async (child) => JSON.parse(await child.waitFor("LOCK")) as { ok: boolean })); + assert.equal(results.filter((result) => result.ok).length, 1); + const winner = contenders[results.findIndex((result) => result.ok)]!; + assert.equal((await acquireBotProfileLock(dir)).ok, false); + await winner.kill(); + const recovered = await acquireBotProfileLock(dir); + assert.equal(recovered.ok, true); + if (recovered.ok) await recovered.release(); + } finally { await Promise.all(contenders.map((child) => child.kill())); } +}); + +test("a stopped live owner survives missing diagnostic metadata", async (t) => { + if (process.platform === "win32") return t.skip("SIGSTOP is unavailable"); + const dir = tempBotsDir(); + const owner = spawnHarnessChild("lock", [dir]); + try { + assert.deepEqual(JSON.parse(await owner.waitFor("LOCK")), { ok: true }); + owner.child.kill("SIGSTOP"); + rmSync(path.join(dir, BOT_PROFILE_LOCK_FILE)); + const contender = spawnHarnessChild("lock", [dir]); + try { assert.equal(JSON.parse(await contender.waitFor("LOCK")).ok, false); } + finally { await contender.kill(); } + } finally { + owner.child.kill("SIGCONT"); + await owner.kill(); + } + const recovered = await acquireBotProfileLock(dir); + assert.equal(recovered.ok, true); + if (recovered.ok) await recovered.release(); +}); + +test("a symlinked guard fails closed and leaves its target untouched", async () => { + const dir = tempBotsDir(); + const first = await acquireBotProfileLock(dir); + assert.equal(first.ok, true); + if (first.ok) await first.release(); + const guard = path.join(dir, BOT_PROFILE_GUARD_FILE); + const target = path.join(path.dirname(dir), "target"); + writeFileSync(target, "untouched"); + rmSync(guard); + symlinkSync(target, guard); + await assert.rejects(acquireBotProfileLock(dir)); + assert.equal(readFileSync(target, "utf8"), "untouched"); +}); + +test("a guard made readable by other users fails closed", async () => { + const dir = tempBotsDir(); + const first = await acquireBotProfileLock(dir); + assert.equal(first.ok, true); + if (first.ok) await first.release(); + chmodSync(path.join(dir, BOT_PROFILE_GUARD_FILE), 0o644); + await assert.rejects(acquireBotProfileLock(dir), /Unsafe lease guard/); +}); + test("a lock naming a pid that does not exist is reclaimed", async () => { const dir = tempBotsDir(); const first = await acquireBotProfileLock(dir); @@ -76,6 +154,20 @@ test("a lock naming a pid that does not exist is reclaimed", async () => { if (lock.ok) await lock.release(); }); +test("new-protocol metadata with a recycled live pid is reclaimed, while legacy metadata is respected", async () => { + const dir = tempBotsDir(); + const file = path.join(dir, BOT_PROFILE_LOCK_FILE); + const initial = await acquireBotProfileLock(dir); + assert.equal(initial.ok, true); + if (initial.ok) await initial.release(); + writeFileSync(file, JSON.stringify({ pid: process.pid, startedAt: 1, token: "legacy" })); + assert.deepEqual(await acquireBotProfileLock(dir), { ok: false, reason: "held_by_live_process", pid: process.pid }); + writeFileSync(file, JSON.stringify({ protocol: "flock-v1", pid: process.pid, startedAt: 1, token: "retired" })); + const recovered = await acquireBotProfileLock(dir); + assert.equal(recovered.ok, true); + if (recovered.ok) await recovered.release(); +}); + test("an unreadable lock file is treated as stale", async () => { const dir = tempBotsDir(); const first = await acquireBotProfileLock(dir); diff --git a/main/services/bot-runtime/profile-lock.ts b/main/services/bot-runtime/profile-lock.ts index 35fc5b7c9..b44a328e5 100644 --- a/main/services/bot-runtime/profile-lock.ts +++ b/main/services/bot-runtime/profile-lock.ts @@ -1,17 +1,13 @@ -// Exclusive per-profile lock for the durable Bot runtime. -// -// Pi Durable storage has no cross-process locking: one process must own a -// Bot's `session.sqlite` at a time. Every Bot harness in a profile is opened -// only while this process holds `/bots/runtime.lock`. A second Aiden -// process (or anything else that tries to host Bots) is refused instead of -// opening the same databases concurrently. - +// One process must own every Bot session database in a profile. The permanent +// OS flock guard is authoritative; runtime.lock is diagnostic legacy metadata. import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { desktopLeaseHelperPath, tryAcquireProcessLifetimeLock } from "../process-lifetime-lock.js"; import { syncDirectory, writeFileAtomic } from "../durable-fs.js"; export const BOT_PROFILE_LOCK_FILE = "runtime.lock"; +export const BOT_PROFILE_GUARD_FILE = "runtime.lease.lock"; export type ProfileLockResult = | { ok: true; release(): Promise } @@ -21,126 +17,87 @@ interface LockRecord { pid: number; startedAt: number; token: string; + protocol?: "flock-v1"; } -/** Tokens of locks this process currently holds, so a same-process reacquire is refused too. */ -const heldTokens = new Set(); -const MAX_ATTEMPTS = 4; +const reservedProfiles = new Set(); function parseLockRecord(raw: string): LockRecord | null { try { const value = JSON.parse(raw) as Partial; - if ( - typeof value.pid === "number" && - Number.isSafeInteger(value.pid) && - value.pid > 0 && - typeof value.startedAt === "number" && - typeof value.token === "string" - ) { - return { pid: value.pid, startedAt: value.startedAt, token: value.token }; + if (typeof value.pid === "number" && Number.isSafeInteger(value.pid) && value.pid > 0 && + typeof value.startedAt === "number" && typeof value.token === "string") { + return { pid: value.pid, startedAt: value.startedAt, token: value.token, + ...(value.protocol === "flock-v1" ? { protocol: "flock-v1" as const } : {}) }; } - } catch { - // A torn or foreign file is treated as stale below. - } + } catch { /* malformed legacy metadata */ } return null; } function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - // EPERM: the process exists but belongs to someone else. - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - -/** A lock whose holder is still alive. Our own pid counts only for a lock this process took. */ -function isLiveHolder(record: LockRecord): boolean { - if (record.pid === process.pid) return heldTokens.has(record.token); - return processIsAlive(record.pid); + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } -async function readLock(file: string): Promise<{ raw: string; record: LockRecord | null } | null> { - try { - const raw = await fs.readFile(file, "utf8"); - return { raw, record: parseLockRecord(raw) }; - } catch (error) { +async function readRecord(file: string): Promise { + try { return parseLockRecord(await fs.readFile(file, "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } -/** - * Remove a stale lock without deleting a fresh one another process published - * meanwhile: move it aside under a unique name, and only discard it when the - * moved bytes are the stale bytes we judged. - */ -async function reclaimStaleLock(dir: string, file: string, staleRaw: string): Promise { - const aside = path.join(dir, `.${BOT_PROFILE_LOCK_FILE}.stale-${randomUUID()}`); - try { - await fs.rename(file, aside); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - const moved = await fs.readFile(aside, "utf8").catch(() => null); - if (moved === staleRaw || moved === null) { - await fs.rm(aside, { force: true }); - return; - } - // We moved a lock that someone else just published; put it back if the - // name is still free. Losing that race means the other process's lock was - // already replaced by a third one, which then owns the profile. - try { - await fs.link(aside, file); - } catch { - // EEXIST: a newer lock is in place. - } - await fs.rm(aside, { force: true }); -} - -/** - * Acquire the exclusive Bot runtime lock in `dir` (the profile's `bots/` - * directory). A lock held by a live process is refused; a lock left by a dead - * process is reclaimed. - */ +/** Acquire before any Bot session database is opened. */ export async function acquireBotProfileLock(dir: string): Promise { await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const file = path.join(dir, BOT_PROFILE_LOCK_FILE); - const record: LockRecord = { pid: process.pid, startedAt: Date.now(), token: randomUUID() }; - const raw = `${JSON.stringify(record)}\n`; - - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { - try { - await writeFileAtomic(file, raw, { exclusive: true, mode: 0o600 }); - heldTokens.add(record.token); - return { ok: true, release: createRelease(dir, file, record) }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const canonical = await fs.realpath(dir); + const file = path.join(canonical, BOT_PROFILE_LOCK_FILE); + if (reservedProfiles.has(canonical)) return { ok: false, reason: "held_by_live_process", pid: process.pid }; + reservedProfiles.add(canonical); + let releaseGuard: (() => void) | null = null; + let published = false; + let replacedLegacy = false; + try { + releaseGuard = tryAcquireProcessLifetimeLock(path.join(canonical, BOT_PROFILE_GUARD_FILE), desktopLeaseHelperPath()); + if (!releaseGuard) { + const owner = await readRecord(file); + return { ok: false, reason: "held_by_live_process", pid: owner?.pid ?? 0 }; + } + const legacy = await readRecord(file); + // An old binary does not honor the guard. A live legacy record must not + // be displaced during an upgrade, even though mixed-version use is unsafe. + if (legacy && legacy.protocol !== "flock-v1" && processIsAlive(legacy.pid)) { + return { ok: false, reason: "held_by_live_process", pid: legacy.pid }; } - const existing = await readLock(file); - if (existing === null) continue; - if (existing.record !== null && isLiveHolder(existing.record)) { - return { ok: false, reason: "held_by_live_process", pid: existing.record.pid }; + await fs.rm(file, { force: true }); + replacedLegacy = true; + const record: LockRecord = { protocol: "flock-v1", pid: process.pid, startedAt: Date.now(), token: randomUUID() }; + await writeFileAtomic(file, `${JSON.stringify(record)}\n`, { exclusive: true, mode: 0o600 }); + published = true; + let released: Promise | null = null; + const heldGuard = releaseGuard; + return { + ok: true, + release: () => { + released ??= (async () => { + try { + await fs.rm(file, { force: true }); + await syncDirectory(canonical).catch(() => undefined); + } finally { + heldGuard(); + reservedProfiles.delete(canonical); + } + })(); + return released; + }, + }; + } finally { + if (!published) { + try { if (replacedLegacy) await fs.rm(file, { force: true }); } + finally { + releaseGuard?.(); + reservedProfiles.delete(canonical); + } } - await reclaimStaleLock(dir, file, existing.raw); } - const holder = await readLock(file); - return { ok: false, reason: "held_by_live_process", pid: holder?.record?.pid ?? 0 }; -} - -function createRelease(dir: string, file: string, record: LockRecord): () => Promise { - let released: Promise | null = null; - return () => { - released ??= (async () => { - heldTokens.delete(record.token); - const current = await readLock(file); - // Never delete a lock another process reclaimed after ours went stale. - if (current?.record?.token !== record.token) return; - await fs.rm(file, { force: true }); - await syncDirectory(dir).catch(() => undefined); - })(); - return released; - }; } diff --git a/main/services/bot-runtime/test-support/kill-harness.mjs b/main/services/bot-runtime/test-support/kill-harness.mjs index dca06feac..33b40a080 100644 --- a/main/services/bot-runtime/test-support/kill-harness.mjs +++ b/main/services/bot-runtime/test-support/kill-harness.mjs @@ -1,4 +1,4 @@ -/* global process, setInterval */ +/* global process, setInterval, setTimeout */ // Child process for the Bot runtime crash tests. The parent spawns it with // `node --import tsx kill-harness.mjs ...`, waits for a marker line on @@ -12,6 +12,7 @@ // scenario run `run(args, emit)` from a test-owned scenario module import { pathToFileURL } from "node:url"; +import { existsSync } from "node:fs"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createRegistry } from "@earendil-works/pi-durable"; import { acquireBotProfileLock } from "../profile-lock.ts"; @@ -33,6 +34,12 @@ if (mode === "lock") { const result = await acquireBotProfileLock(args[0]); emit("LOCK", result.ok ? { ok: true } : { ok: false, reason: result.reason, pid: result.pid }); holdForever(); +} else if (mode === "lock-gated") { + emit("READY", "ready"); + while (!existsSync(args[1])) await new Promise((resolve) => setTimeout(resolve, 10)); + const result = await acquireBotProfileLock(args[0]); + emit("LOCK", result.ok ? { ok: true } : { ok: false, reason: result.reason, pid: result.pid }); + holdForever(); } else if (mode === "host") { const { models } = createFauxModels(); const host = await createBotHarnessHost({ profileDir: args[0], buildRegistry: () => createRegistry(), models }); diff --git a/main/services/process-lifetime-lock.ts b/main/services/process-lifetime-lock.ts new file mode 100644 index 000000000..fbdf50038 --- /dev/null +++ b/main/services/process-lifetime-lock.ts @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import { closeSync, constants, fstatSync, mkdirSync, openSync, realpathSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +const heldPaths = new Set(); + +export function desktopLeaseHelperPath(runtime: { + defaultApp: boolean; + resourcesPath?: string; + cwd: string; +} = { + defaultApp: process.defaultApp === true, + resourcesPath: typeof process.resourcesPath === "string" ? process.resourcesPath : undefined, + cwd: process.cwd(), +}): string { + return runtime.defaultApp !== true && runtime.resourcesPath + ? resolve(runtime.resourcesPath, "..", "Helpers", "aiden-lease-flock") + : resolve(runtime.cwd, "build", "native", "aiden-lease-flock"); +} + +/** + * `flock` belongs to an open file description on macOS and Linux. The short + * helper locks a duplicate of our fd 3, then exits; this process retains the + * same description until release or death. An unrelated read/open/close of the + * guard cannot cancel this lock, unlike a POSIX SQLite transaction lock. + */ +export function tryAcquireProcessLifetimeLock(file: string, helper: string): (() => void) | null { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const canonical = join(realpathSync(dirname(file)), basename(file)); + if (heldPaths.has(canonical)) return null; + heldPaths.add(canonical); + let fd: number | undefined; + try { + fd = openSync(canonical, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || + (process.getuid !== undefined && stat.uid !== process.getuid())) { + throw new Error(`Unsafe lease guard at ${canonical}`); + } + const result = spawnSync(helper, [], { + stdio: ["ignore", "pipe", "pipe", fd], + timeout: 5_000, + encoding: "utf8", + }); + if (result.error || result.signal || (result.status !== 0 && result.status !== 10)) { + throw new Error(`Cannot lock ${canonical}: ${result.error?.message ?? result.signal ?? result.stderr?.trim() ?? `helper exit ${result.status}`}`); + } + if (result.status === 10) { + closeSync(fd); + heldPaths.delete(canonical); + return null; + } + } catch (error) { + if (fd !== undefined) closeSync(fd); + heldPaths.delete(canonical); + throw error; + } + const heldFd = fd; + let released = false; + return () => { + if (released) return; + released = true; + closeSync(heldFd); + heldPaths.delete(canonical); + }; +} diff --git a/native/lease-flock/main.c b/native/lease-flock/main.c new file mode 100644 index 000000000..cb7c28e77 --- /dev/null +++ b/native/lease-flock/main.c @@ -0,0 +1,9 @@ +#include +#include + +/* fd 3 is a duplicate of the parent's open file description. The flock stays + * with that description after this helper exits, until the parent closes it. */ +int main(void) { + if (flock(3, LOCK_EX | LOCK_NB) == 0) return 0; + return (errno == EWOULDBLOCK || errno == EAGAIN) ? 10 : 11; +} diff --git a/package.json b/package.json index 6aa065565..5fe6be4ec 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "build:native": "npm run build:native:c && node scripts/build-foundation-models-helper.mjs --required && node scripts/build-cua-s1-forms-helper.mjs --required", "build:native:optional": "npm run build:native:c && node scripts/build-foundation-models-helper.mjs --optional && node scripts/build-cua-s1-forms-helper.mjs --optional", "build:bot-inbox-writer": "node scripts/build-bot-inbox-writer.mjs", + "build:lease-flock": "node scripts/build-lease-flock.mjs", "build:subagent-file-mutator": "node scripts/build-subagent-file-mutator.mjs", "build:subagent-shell-runner": "node scripts/build-subagent-shell-runner.mjs", "build:subagent-run-store": "node scripts/build-subagent-run-store.mjs", @@ -126,7 +127,8 @@ "test:compaction:packaged": "node --import tsx scripts/pi-session-packaged-restart-acceptance.mjs", "test:compaction:evaluate": "node --import tsx scripts/pi-upgrade-evaluation.mjs", "pi-upgrade:advance": "node --import tsx scripts/pi-upgrade-advance.mjs", - "pretest:bots": "npm run build:bot-inbox-writer && node scripts/build-bot-inbox-writer.mjs --test && node --test scripts/bot-inbox-writer.test.mjs && tsx --test main/services/bot-share-image-tool.test.ts main/services/bot-inbound-attachment-inbox.test.ts main/services/vision-analysis-tool.test.ts", + "pretest:bots": "npm run build:lease-flock && npm run build:bot-inbox-writer && node scripts/build-bot-inbox-writer.mjs --test && node --test scripts/bot-inbox-writer.test.mjs && tsx --test main/services/bot-share-image-tool.test.ts main/services/bot-inbound-attachment-inbox.test.ts main/services/vision-analysis-tool.test.ts", + "pretest:bot-runtime": "npm run build:lease-flock", "test:telegram": "tsx --test main/services/telegram/bot-reply-outbox.test.ts main/services/telegram/telegram-profile-mutation-fence.test.ts main/services/telegram/telegram-controls.test.ts main/services/telegram/telegram-inbound.test.ts main/services/telegram/telegram-outbound.test.ts main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-session.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts main/services/telegram/telegram-activity.test.ts main/services/telegram/telegram-profile-config.test.ts main/services/telegram/telegram-extension-registry.test.ts main/services/telegram/telegram-thread-store.test.ts main/services/telegram/telegram-ownership.test.ts main/services/telegram/telegram-agent-tools.test.ts main/services/telegram/telegram-bot-binding-store.test.ts main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts renderer/lib/telegram-workspace-options.test.ts", "test:bots": "tsx --test main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts main/services/bot-avatar-store.test.ts main/services/bot-store-core.test.ts main/services/bot-chat-store.test.ts main/services/bot-mutation-gate.test.ts main/services/bot-inbox-projection.test.ts main/services/bot-system-prompt.test.ts main/services/bot-generation-preparation.test.ts main/services/bot-inbound-attachment-home.test.ts main/services/bot-file-tool-router.test.ts main/services/bot-tool-authority.test.ts main/services/bot-capability-store-core.test.ts main/services/bot-capability-store.test.ts main/services/bot-capability-state-checkpoint.test.ts main/services/bot-capability-keychain-anchor.test.ts main/services/bot-capability-secret-service-anchor.test.ts main/services/bot-capability-lease.test.ts main/services/bot-runtime-inventory-lease.test.ts main/services/bot-runtime-inventory-publication.test.ts main/services/bot-runtime-authority.test.ts main/services/bot-capability-catalog-core.test.ts main/services/bot-capability-bindings.test.ts main/services/bot-capability-key-store.test.ts main/services/bot-capability-migration-seal.test.ts main/services/bot-capability-incarnation-store.test.ts main/services/bot-capability-inventory-ports.test.ts main/services/bot-capability-production-shape.test.ts main/services/bot-mcp-inventory.test.ts main/services/bot-skill-inventory.test.ts main/services/bot-skill-content-watcher.test.ts main/services/bot-managed-workspace-core.test.ts main/services/bot-lifecycle-journal-core.test.ts main/services/bot-application-service.test.ts main/handlers/bot-params.test.ts main/handlers/bot-renderer-errors.test.ts main/handlers/bots.contract.test.ts main/handlers/bot-access.test.ts main/handlers/bot-telegram.test.ts renderer/main/bots-view.test.tsx renderer/main/bots/bot-character-card.test.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-chat-mode.test.tsx renderer/main/bots/bot-delete-dialog.test.tsx renderer/main/bots/bots-home.test.tsx renderer/main/bots/bot-create-flow.test.tsx renderer/main/bots/bot-profile.test.tsx renderer/main/bots/bot-profile-photo.test.tsx renderer/lib/model-picker-data.test.ts renderer/lib/command-system-core.test.ts renderer/shared/bot-capabilities.test.ts renderer/shared/bot-presets.test.ts renderer/shared/bot-connections.test.ts renderer/components/bots/bot-connection-ui.test.tsx main/services/bot-runtime-tools/suggest-connection.test.ts main/services/bot-connection-dismissals.test.ts main/handlers/bot-live.test.ts main/handlers/bot-questions.test.ts renderer/main/bots/bot-chat-pane.test.tsx renderer/main/bots/bot-routines.test.tsx main/services/bot-connection-setup-core.test.ts main/handlers/bot-files.test.ts renderer/main/bots/bot-transcript-rows.test.ts renderer/main/bots/bot-memory-page.test.tsx main/services/bot-routine-notes.test.ts main/services/bot-routine-proposals.test.ts main/services/bot-routine-notifications.test.ts renderer/main/bots/bot-model-choice.test.ts renderer/main/bots/bot-model-picker.test.tsx renderer/shared/bot-activity.test.ts renderer/main/bots/bot-status-line.test.tsx renderer/main/bots/bot-activity-recap.test.tsx renderer/main/bots/bot-chat-activity-page.test.tsx renderer/lib/bot-routes.test.ts renderer/lib/bot-sidebar-preferences.test.ts renderer/lib/search-fold.test.ts renderer/main/bots/use-bot-sidebar-preferences.test.tsx renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-activity-log.test.ts renderer/main/bots/bot-sidebar.test.tsx", "test:bot-runtime": "tsx --test main/services/bot-runtime/profile-lock.test.ts main/services/bot-runtime/harness-host.test.ts main/services/bot-runtime/tool-adapter.test.ts main/services/bot-runtime/bot-extension.test.ts main/services/bot-runtime/bot-session-service.test.ts main/services/bot-runtime/bot-tool-assembly.test.ts main/services/bot-runtime/bot-delete.test.ts main/services/bot-runtime/live-projection.test.ts main/services/bot-runtime/bot-attention.test.ts main/services/bot-runtime/bot-starter.test.ts main/services/bot-runtime/bot-question-tool.test.ts main/services/bot-memory/store.test.ts main/services/bot-memory/scan.test.ts main/services/bot-memory/tool.test.ts main/services/bot-memory/compaction.test.ts main/services/bot-memory/review.test.ts main/handlers/bot-memory.test.ts main/services/bot-runtime/bot-activity-detail.test.ts", @@ -408,6 +410,7 @@ "Contents/Helpers/Aiden Foundation Models Helper.app", "Contents/Helpers/Aiden CUA-S1 Forms Helper.app", "Contents/Helpers/aiden-bot-inbox-writer", + "Contents/Helpers/aiden-lease-flock", "Contents/Helpers/aiden-subagent-run-store", "Contents/Helpers/aiden-subagent-file-mutator", "Contents/Helpers/aiden-subagent-shell-runner", @@ -449,6 +452,10 @@ "from": "build/native/aiden-bot-inbox-writer", "to": "Helpers/aiden-bot-inbox-writer" }, + { + "from": "build/native/aiden-lease-flock", + "to": "Helpers/aiden-lease-flock" + }, { "from": "build/native/aiden-subagent-run-store", "to": "Helpers/aiden-subagent-run-store" @@ -497,6 +504,10 @@ "from": "build/native/aiden-bot-inbox-writer", "to": "Helpers/aiden-bot-inbox-writer" }, + { + "from": "build/native/aiden-lease-flock", + "to": "Helpers/aiden-lease-flock" + }, { "from": "build/native/aiden-subagent-run-store", "to": "Helpers/aiden-subagent-run-store" diff --git a/packages/cli/README.md b/packages/cli/README.md index 3943d5abe..cc02b0b03 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -117,7 +117,7 @@ selection and exact MCP connection bindings. - `aiden provider import ` configures custom compatible providers. `aiden catalog refresh` refreshes inference catalogs. `catalog models-dev fetch` and `insights aa|openrouter fetch` are explicit display-only network actions. - `aiden mcp presets`, `mcp add `, and `mcp login ` configure MCP. Tool schemas are paginated, bounded, and rechecked before execution; failed servers do not silently gain tools. - `aiden schedule save ` validates a schedule; `schedule preview`, `runs`, `notifications`, `run`, `pause`, and `resume` inspect/control it. `aiden serve` owns the scheduler, configured Telegram polling, and Bot runtime. `schedule` and `workspace` commands work without the daemon via a one-shot lease; `bots`, `remote`, and `telegram` management commands require `aiden serve` already running and fail if it is not. Run completions surface through `aiden schedule runs `, `aiden schedule notifications`, and — for tasks with `notify` — local notifications on paired phones via the Remote `scheduled-tasks/notifications` feed. -- `aiden serve --daemon [--remote]` runs the daemon in the background (logs to `/serve.log`); `aiden serve status` reports PID/socket and `aiden serve stop` shuts it down. Stale locks are recovered only when the recorded owner PID is dead. +- `aiden serve --daemon [--remote]` runs the daemon in the background (logs to `/serve.log`); `aiden serve status` reports PID/socket and `aiden serve stop` shuts it down. Each lease holds an OS lock on a permanent `*.lease.lock` guard. Process death releases the lock; owner JSON is diagnostic and stale metadata is cleaned only under the guard. Never delete or replace guard files. Stop old CLI processes and daemons before upgrading a shared agent directory because old binaries do not use this guard. - `aiden serve --remote` additionally exposes the shared authenticated HTTPS/SSE API. `aiden remote pair lan|tailscale` renders a scannable terminal QR plus the manual code; `remote pair-status`/`remote pair-cancel ` manage the ~5-minute pairing window, and `remote devices`, `remote revoke `, `remote approve-root ` administer pairing state. Tailscale requires its CLI on the host and an explicit pairing action. Sessions created through the daemon (Remote, Telegram, Bot replies) run the headless-safe extension set — memory, todo, MCP, web search, advisor, and artifact export; image display and ask-user-question remain interactive-TUI only. - `aiden bots notice`, `acknowledge`, `catalog`, `create`, and `access` use the shared notice/revision/capability contracts. Bots support managed homes, exact Custom grants, MCP, skills, web search, companion vision, and uploaded photos. Bot subagents perform read-only investigations within the admitted file scopes. - `aiden speech status` inspects local models; `download`, `select`, `delete`, and `transcribe` are explicit actions. Optional `sherpa-onnx-node` runs in a worker. Setup and ordinary status reads never download weights. diff --git a/packages/cli/src/serve-lifecycle.ts b/packages/cli/src/serve-lifecycle.ts index 493c25e40..975ee7217 100644 --- a/packages/cli/src/serve-lifecycle.ts +++ b/packages/cli/src/serve-lifecycle.ts @@ -5,27 +5,30 @@ * * The same `serve.lock` is also taken briefly by one-shot `schedule`/`workspace` * commands when the daemon is down, so PID verification distinguishes three - * cases — our daemon (` serve`), another live Aiden process holding the - * lock legitimately, and a recycled foreign PID — and only the last is cleared. + * cases for legacy metadata — our daemon (` serve`), another live Aiden + * process holding the lock legitimately, and a recycled foreign PID. Newer + * metadata is stale whenever its OS guard is free, even if the PID was reused. */ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; import { join, relative, resolve } from "node:path"; import { daemonSocket } from "./daemon-control.ts"; -import { acquireLease, readJson } from "./state.ts"; +import { acquireLease, cliLeaseHelperPath, leaseGuardPath, readJson } from "./state.ts"; +import { tryAcquireProcessLifetimeLock } from "../../../main/services/process-lifetime-lock.ts"; const SERVE_LOCK = "serve.lock"; const SERVE_LOG = "serve.log"; const START_LOCK = "serve-start"; -/** A lock with no owner.json younger than this may still be mid-acquireLease. */ -const FRESH_LOCK_MS = 2_000; interface ServeOwner { + protocol?: string; pid?: number; /** Recorded by acquireLease — the owner's own argv, verbatim. */ argv?: unknown; startedAt?: number; + token?: string; } function serveOwner(agentDir: string): ServeOwner | undefined { @@ -137,42 +140,40 @@ function classifyPid(pid: number, owner?: ServeOwner): PidClass { return matches("(?:\\s|$)") ? "aiden" : "foreign"; } -function clearServeArtifacts(agentDir: string): void { - rmSync(join(agentDir, SERVE_LOCK), { recursive: true, force: true }); - rmSync(daemonSocket(agentDir), { force: true }); +function sameOwner(current: ServeOwner | undefined, expected: ServeOwner): boolean { + return current?.pid === expected.pid && current?.startedAt === expected.startedAt && + current?.token === expected.token && current?.protocol === expected.protocol; +} + +/** Cleanup owns the same permanent guard as the daemon. Re-evaluate after taking it. */ +function clearServeArtifacts(agentDir: string, expected?: ServeOwner): boolean { + const lock = join(agentDir, SERVE_LOCK); + const release = tryAcquireProcessLifetimeLock(leaseGuardPath(join(agentDir, "serve")), cliLeaseHelperPath()); + if (!release) return false; + try { + const current = serveOwner(agentDir); + if (expected && !sameOwner(current, expected)) return false; + if (current && current.protocol !== "flock-v1" && validPid(current.pid) && pidAlive(current.pid) && classifyPid(current.pid, current) !== "foreign") return false; + if (!existsSync(lock)) return false; + rmSync(lock, { recursive: true, force: true }); + rmSync(daemonSocket(agentDir), { force: true }); + return true; + } finally { release(); } } /** * Drop a serve lock left behind by a killed daemon or a crashed one-shot - * command. Only reclaims when the recorded owner is dead or verifiably a - * recycled foreign PID — a live Aiden process (daemon or command) and an - * uninspectable owner are never disturbed. + * command. The guard decides for new metadata; legacy metadata also requires + * a dead owner or a verifiably recycled foreign PID. */ export function recoverStaleServeLease(agentDir: string): boolean { const lock = join(agentDir, SERVE_LOCK); if (!existsSync(lock)) return false; - const owner = serveOwner(agentDir); - if (!owner || !validPid(owner.pid)) { - // Missing/corrupt owner.json: acquireLease creates the lock dir before - // writing owner.json, so a very fresh lock may belong to a live acquirer. - try { - if (Date.now() - statSync(lock).mtimeMs < FRESH_LOCK_MS) return false; - } catch { /* fall through */ } - clearServeArtifacts(agentDir); - return true; - } - if (!pidAlive(owner.pid) || classifyPid(owner.pid, owner) === "foreign") { - // Recheck right before removing: an acquirer that won the lock between - // our read and now must not have its fresh lease deleted (check-then-act). - const current = serveOwner(agentDir); - if (current && validPid(current.pid) && current.pid !== owner.pid) return false; - clearServeArtifacts(agentDir); - return true; - } - return false; + return clearServeArtifacts(agentDir); } export function daemonStatus(agentDir: string): { running: boolean; pid?: number; startedAt?: number; socket: string; log: string } { + recoverStaleServeLease(agentDir); const owner = serveOwner(agentDir); const socket = daemonSocket(agentDir); const klass = owner && validPid(owner.pid) && pidAlive(owner.pid) @@ -199,7 +200,12 @@ async function waitForSocket(agentDir: string, child: Pick((resolveReady) => { + const connection = createConnection(socket); + const timer = setTimeout(() => { connection.destroy(); resolveReady(false); }, 250); + connection.once("connect", () => { clearTimeout(timer); connection.destroy(); resolveReady(true); }); + connection.once("error", () => { clearTimeout(timer); resolveReady(false); }); + })) return; await new Promise((resolveWait) => setTimeout(resolveWait, 150)); } throw new Error(`The daemon did not open its control socket within ${Math.round(timeoutMs / 1000)}s. See ${join(agentDir, SERVE_LOG)}.`); @@ -230,10 +236,9 @@ export async function startDaemon(agentDir: string, options: { remote?: boolean; mkdirSync(agentDir, { recursive: true, mode: 0o700 }); const releaseStart = acquireStartLease(agentDir); try { - if (daemonStatus(agentDir).running) throw new Error("The Aiden daemon is already running."); recoverStaleServeLease(agentDir); - // A leftover socket would satisfy the readiness check before the child boots. - rmSync(daemonSocket(agentDir), { force: true }); + if (daemonStatus(agentDir).running) throw new Error("The Aiden daemon is already running."); + // The child removes a leftover socket after it acquires the serve lease. const entry = process.argv[1]; if (!entry) throw new Error("Cannot determine the Aiden CLI entry point for a background daemon."); const log = join(agentDir, SERVE_LOG); @@ -287,6 +292,7 @@ export async function startDaemon(agentDir: string, options: { remote?: boolean; /** SIGTERM the daemon recorded in the serve lease and wait for it to release. */ export async function stopDaemon(agentDir: string, timeoutMs = 15_000): Promise<{ stopped: boolean; pid?: number }> { + recoverStaleServeLease(agentDir); const owner = serveOwner(agentDir); if (!owner || !validPid(owner.pid)) { recoverStaleServeLease(agentDir); @@ -294,13 +300,13 @@ export async function stopDaemon(agentDir: string, timeoutMs = 15_000): Promise< } const pid = owner.pid; if (!pidAlive(pid)) { - clearServeArtifacts(agentDir); + clearServeArtifacts(agentDir, owner); throw new Error(`The Aiden daemon was not running; cleared the stale lock left by PID ${pid}.`); } const klass = classifyPid(pid, owner); if (klass === "foreign") { // The PID was recycled by an unrelated process — never signal it. - clearServeArtifacts(agentDir); + clearServeArtifacts(agentDir, owner); throw new Error(`The Aiden daemon was not running; cleared the stale lock left by PID ${pid}.`); } if (klass === "aiden") { @@ -317,7 +323,7 @@ export async function stopDaemon(agentDir: string, timeoutMs = 15_000): Promise< } catch (error) { // The daemon died between classification and the signal. if ((error as NodeJS.ErrnoException).code === "ESRCH") { - clearServeArtifacts(agentDir); + clearServeArtifacts(agentDir, owner); return { stopped: true, pid }; } throw error; @@ -326,11 +332,9 @@ export async function stopDaemon(agentDir: string, timeoutMs = 15_000): Promise< let polls = 0; while (Date.now() < deadline) { if (!existsSync(join(agentDir, SERVE_LOCK)) && !existsSync(daemonSocket(agentDir))) return { stopped: true, pid }; + if (!sameOwner(serveOwner(agentDir), owner)) return { stopped: true, pid }; if (!pidAlive(pid) || (polls % 10 === 9 && classifyPid(pid, owner) === "foreign")) { - // Our daemon is gone; if the lease changed hands meanwhile, the new - // owner is not ours to clear. - const current = serveOwner(agentDir); - if (!current || !validPid(current.pid) || current.pid === pid) clearServeArtifacts(agentDir); + clearServeArtifacts(agentDir, owner); return { stopped: true, pid }; } polls += 1; diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 041f38e51..51db79a15 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -2,6 +2,7 @@ import { createServer } from "node:net"; import { chmodSync, rmSync } from "node:fs"; import { join } from "node:path"; import { acquireLease } from "./state.ts"; +import { recoverStaleServeLease } from "./serve-lifecycle.ts"; import { createCliScheduler, daemonSocket } from "./schedules.ts"; import { createDaemonChats } from "./daemon-chats.ts"; import { createCliTelegram } from "./telegram.ts"; @@ -9,7 +10,6 @@ import { workspaceCommand } from "./workspaces.ts"; import { createCliWorkspaceApplication } from "./workspace-application.ts"; import { createCliBots } from "./bots.ts"; import { createCliRemote } from "./remote.ts"; -import { recoverStaleServeLease } from "./serve-lifecycle.ts"; /** Local control socket is private to the agent directory's owner. */ export async function serve(agentDir: string, signal: AbortSignal, options: { remote?: boolean } = {}) { diff --git a/packages/cli/src/state.ts b/packages/cli/src/state.ts index 89b0ca12b..6a889b2b1 100644 --- a/packages/cli/src/state.ts +++ b/packages/cli/src/state.ts @@ -1,6 +1,8 @@ -import { mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, closeSync, fsyncSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, closeSync, fsyncSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { tryAcquireProcessLifetimeLock } from "../../../main/services/process-lifetime-lock.ts"; export function readJson(file: string, fallback: T): T { try { return JSON.parse(readFileSync(file, "utf8")) as T; } @@ -23,21 +25,15 @@ export function atomicJson(file: string, value: unknown): void { } finally { rmSync(temporary, { force: true }); } } -/** A lock without owner.json younger than this may still be mid-acquire. */ -const FRESH_LOCK_MS = 2_000; - -/** - * Whether the recorded lock owner is a live process. Returns "fresh" for a - * missing/corrupt owner.json inside the mid-acquire window — that lock may - * belong to a live acquirer between mkdir and the owner write. - */ -function lockOwnerIsLive(lock: string): boolean | "fresh" { - let pid: unknown; - try { pid = (JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as { pid?: unknown }).pid; } - catch { - try { if (Date.now() - statSync(lock).mtimeMs < FRESH_LOCK_MS) return "fresh"; } catch { /* gone */ } - return false; - } +/** Legacy owner metadata is consulted only after the permanent guard is held. */ +function lockOwnerIsLive(lock: string): boolean { + let owner: unknown; + try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as unknown; } + catch { return false; } + if (owner === null || typeof owner !== "object" || Array.isArray(owner)) return false; + const record = owner as { pid?: unknown; protocol?: unknown }; + if (record.protocol === "flock-v1") return false; + const pid = record.pid; if (typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0) { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } @@ -49,46 +45,48 @@ function lockOwnerIsLive(lock: string): boolean | "fresh" { export function acquireLease(file: string): () => void { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); const lock = `${file}.lock`; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { mkdirSync(lock, { mode: 0o700 }); break; } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - // A crashed holder (kill -9, power loss) leaves its lock behind; reclaim - // it when the recorded owner is provably dead or stale debris. A live - // owner — or one inside the mid-acquire window — keeps the lock. - if (attempt === 0 && lockOwnerIsLive(lock) === false) { - rmSync(lock, { recursive: true, force: true }); - continue; - } - throw new Error(`Another Aiden process owns ${file}. If it crashed, remove ${lock} after checking the owner.json PID.`); - } - } + const releaseGuard = tryAcquireProcessLifetimeLock(leaseGuardPath(file), cliLeaseHelperPath()); + if (!releaseGuard) throw new Error(`Another Aiden process owns ${file}.`); + let mayHavePublished = false; // argv is recorded so lifecycle checks can verify the OWNER's exact // invocation rather than guessing from the checking process's argv/cwd. - try { atomicJson(`${lock}/owner.json`, { pid: process.pid, argv: process.argv.slice(0, 5), startedAt: Date.now() }); } - catch (error) { rmSync(lock, { recursive: true, force: true }); throw error; } + try { + if (existsSync(lock)) { + if (lockOwnerIsLive(lock)) throw new Error(`Another Aiden process owns ${file}.`); + rmSync(lock, { recursive: true, force: true }); + } + mkdirSync(lock, { mode: 0o700 }); + mayHavePublished = true; + atomicJson(`${lock}/owner.json`, { protocol: "flock-v1", pid: process.pid, argv: process.argv.slice(0, 5), startedAt: Date.now(), token: randomUUID() }); + } catch (error) { + try { if (mayHavePublished) rmSync(lock, { recursive: true, force: true }); } + finally { releaseGuard(); } + throw error; + } let released = false; return () => { if (released) return; released = true; - // Only remove the lock while owner.json still names this process. If the - // lock was reclaimed and re-acquired meanwhile (or a fresh acquirer sits - // between mkdir and owner write), removing it would orphan a live owner. - // Missing/corrupt owner.json past the mid-acquire window is stale debris — - // reclaim it rather than letting the lease wedge permanently. - let ownerPid: number | undefined; - try { ownerPid = (JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as { pid?: number }).pid; } - catch { /* missing/corrupt — debris rule below */ } - if (ownerPid !== undefined) { - if (ownerPid !== process.pid) return; - rmSync(lock, { recursive: true, force: true }); - return; - } - try { if (Date.now() - statSync(lock).mtimeMs < FRESH_LOCK_MS) return; } catch { return; } - rmSync(lock, { recursive: true, force: true }); + try { rmSync(lock, { recursive: true, force: true }); } + finally { releaseGuard(); } }; } +export function leaseGuardPath(file: string): string { return `${file}.lease.lock`; } + +export function cliLeaseHelperPath(): string { + const here = dirname(fileURLToPath(import.meta.url)); + const entry = process.env.AIDEN_CLI_ENTRY; + const candidates = [ + ...(entry ? [join(dirname(entry), "native/aiden-lease-flock")] : []), + join(here, "native/aiden-lease-flock"), + resolve(here, "../native/aiden-lease-flock"), + join(here, "app/native/aiden-lease-flock"), + resolve(here, "../../../build/native/aiden-lease-flock"), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!; +} + export class JsonStore { readonly file: string; readonly fallback: T; diff --git a/packages/cli/tests/fixtures/lease-child.mjs b/packages/cli/tests/fixtures/lease-child.mjs new file mode 100644 index 000000000..45a42c507 --- /dev/null +++ b/packages/cli/tests/fixtures/lease-child.mjs @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; + +const [mode, file, gate, marker, resultFile] = process.argv.slice(2); +if (mode === "pause-reclaim") { + const remove = fs.rmSync; + fs.rmSync = (target, ...args) => { + if (target === `${file}.lock`) { + fs.writeFileSync(marker, "paused"); + const cell = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(gate)) Atomics.wait(cell, 0, 0, 10); + } + return remove(target, ...args); + }; + syncBuiltinESMExports(); +} + +const { acquireLease } = await import("../../dist/parity-test-api.js"); +try { + acquireLease(file); + fs.writeFileSync(resultFile, `owned:${process.pid}`); + setInterval(() => undefined, 60_000); +} catch (error) { + fs.writeFileSync(resultFile, `contended:${String(error)}`); +} diff --git a/packages/cli/tests/parity.test.mjs b/packages/cli/tests/parity.test.mjs index 8eca0f2eb..c5993df6c 100644 --- a/packages/cli/tests/parity.test.mjs +++ b/packages/cli/tests/parity.test.mjs @@ -6,6 +6,7 @@ import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn, spawnSync } from "node:child_process"; import { createServer } from "node:http"; +import { once } from "node:events"; import { createRequire, syncBuiltinESMExports } from "node:module"; import { request as httpsRequest } from "node:https"; import * as api from "../dist/parity-test-api.js"; @@ -33,6 +34,93 @@ test("atomic stores serialize mutations, protect credentials, and reject cross-p assert.throws(() => api.splitArgs('"unfinished'), /Unfinished/); }); +test("a stale CLI lease cannot be reclaimed by two processes", async (t) => { + const dir = temporary(t), file = join(dir, "shared.json"); + const fixture = join(pkg, "tests", "fixtures", "lease-child.mjs"); + const gate = join(dir, "resume"), marker = join(dir, "paused"); + const staleResult = join(dir, "stale-result"), firstResult = join(dir, "first-result"), secondResult = join(dir, "second-result"); + const children = []; + const launch = (mode, result) => { + const child = spawn(process.execPath, [fixture, mode, file, gate, marker, result], { stdio: "ignore" }); + children.push(child); + return child; + }; + const waitForFile = async (name) => { + const deadline = Date.now() + 20_000; + for (;;) { + const value = existsSync(name) ? readFileSync(name, "utf8") : ""; + if (value) return value; + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + }; + const kill = async (child) => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + }; + try { + const stale = launch("plain", staleResult); + assert.match(await waitForFile(staleResult), /^owned:/); + await kill(stale); + launch("pause-reclaim", firstResult); + await waitForFile(marker); + launch("plain", secondResult); + assert.match(await waitForFile(secondResult), /^contended:/); + writeFileSync(gate, "resume"); + assert.match(await waitForFile(firstResult), /^owned:/); + } finally { + writeFileSync(gate, "resume"); + await Promise.all(children.map(kill)); + } +}); + +test("a failed same-process reacquire keeps the cross-process guard held", async (t) => { + const dir = temporary(t), file = join(dir, "held.json"), result = join(dir, "result"); + const release = api.acquireLease(file); + const child = spawn(process.execPath, [join(pkg, "tests", "fixtures", "lease-child.mjs"), "plain", file, "", "", result], { stdio: "ignore" }); + try { + assert.throws(() => api.acquireLease(file), /Another Aiden process owns/); + readFileSync(api.leaseGuardPath(file)); + const deadline = Date.now() + 20_000; + let outcome = ""; + while (!outcome) { + if (existsSync(result)) outcome = readFileSync(result, "utf8"); + if (Date.now() > deadline) throw new Error("The competing process did not finish acquisition"); + if (!outcome) await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + assert.match(outcome, /^contended:/); + } finally { + release(); + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + } +}); + +test("CLI new-protocol metadata with a recycled live pid is reclaimable", (t) => { + const dir = temporary(t), file = join(dir, "writer.json"), lock = `${file}.lock`; + mkdirSync(lock); + api.atomicJson(join(lock, "owner.json"), { pid: process.pid, startedAt: 1 }); + assert.throws(() => api.acquireLease(file), /Another Aiden process owns/); + api.atomicJson(join(lock, "owner.json"), { protocol: "flock-v1", pid: process.pid, startedAt: 1 }); + const release = api.acquireLease(file); + release(); +}); + +test("CLI lease reclaims null owner metadata under a free guard", (t) => { + const dir = temporary(t), file = join(dir, "writer.json"), lock = `${file}.lock`; + mkdirSync(lock); + writeFileSync(join(lock, "owner.json"), "null"); + const release = api.acquireLease(file); + try { + assert.equal(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).protocol, "flock-v1"); + } finally { release(); } +}); + test("workspace identity is canonical and all tools require one-shot approval in ask mode", async (t) => { const dir = temporary(t), workspace = await api.workspaceCommand(dir, ["add", dir]); assert.equal(api.accessFor(dir, dir), "ask"); @@ -162,6 +250,35 @@ test("serve routes concurrent CLI commands and releases its lease on shutdown", const release = api.acquireLease(join(dir, "serve")); release(); }); +test("foreground serve recovers a legacy stale lease naming a recycled foreign pid", async (t) => { + const dir = temporary(t), lock = join(dir, "serve.lock"); + const foreign = spawn("sleep", ["30"]); + t.after(() => { try { foreign.kill("SIGKILL"); } catch { /* already gone */ } }); + await once(foreign, "spawn"); + mkdirSync(lock); + api.atomicJson(join(lock, "owner.json"), { pid: foreign.pid, startedAt: 1 }); + writeFileSync(api.daemonSocket(dir), "stale socket"); + const controller = new AbortController(); + const serving = api.serve(dir, controller.signal); + let failure; + void serving.catch((error) => { failure = error; }); + try { + let listening = false; + for (let attempt = 0; attempt < 100; attempt++) { + if (failure) throw failure; + try { listening = statSync(api.daemonSocket(dir)).isSocket(); } catch { /* starting */ } + if (listening) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(listening, true, "foreground serve should replace the stale socket"); + assert.equal(api.pidAlive(foreign.pid), true, "recovery must not signal the foreign process"); + assert.equal(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).pid, process.pid); + } finally { + controller.abort(); + await serving.catch(() => undefined); + } +}); + test("serve lifecycle recovers stale leases and never signals a recycled PID", async (t) => { const dir = temporary(t); assert.equal(api.daemonStatus(dir).running, false); @@ -192,7 +309,7 @@ test("serve lifecycle recovers stale leases and never signals a recycled PID", a await new Promise((resolve) => foreign.once("spawn", resolve)); mkdirSync(join(dir, "serve.lock"), { recursive: true }); api.atomicJson(join(dir, "serve.lock", "owner.json"), { pid: foreign.pid, startedAt: Date.now() }); - await assert.rejects(api.stopDaemon(dir), /stale lock/); + await assert.rejects(api.stopDaemon(dir), /not running/); assert.equal(existsSync(join(dir, "serve.lock")), false); assert.equal(api.pidAlive(foreign.pid), true); @@ -211,6 +328,48 @@ test("serve lifecycle recovers stale leases and never signals a recycled PID", a assert.equal(api.pidAlive(process.pid), true); }); +test("serve cleanup cannot remove a replacement owner or socket", async (t) => { + const dir = temporary(t), resource = join(dir, "serve"); + const dead = spawnSync(process.execPath, ["-e", "0"]).pid; + mkdirSync(`${resource}.lock`, { recursive: true }); + api.atomicJson(join(`${resource}.lock`, "owner.json"), { pid: dead, startedAt: 1 }); + writeFileSync(api.daemonSocket(dir), "stale socket"); + const nodeFs = createRequire(import.meta.url)("node:fs"); + const originalRemove = nodeFs.rmSync; + let attempted = false; + nodeFs.rmSync = (target, ...args) => { + if (target === `${resource}.lock`) { + attempted = true; + assert.throws(() => api.acquireLease(resource), /Another Aiden process owns/); + } + return originalRemove(target, ...args); + }; + syncBuiltinESMExports(); + try { assert.equal(api.recoverStaleServeLease(dir), true); } + finally { + nodeFs.rmSync = originalRemove; + syncBuiltinESMExports(); + } + assert.equal(attempted, true); + const release = api.acquireLease(resource); + writeFileSync(api.daemonSocket(dir), "replacement socket"); + try { + assert.equal(api.recoverStaleServeLease(dir), false); + assert.equal(readFileSync(api.daemonSocket(dir), "utf8"), "replacement socket"); + assert.equal(JSON.parse(readFileSync(join(`${resource}.lock`, "owner.json"), "utf8")).pid, process.pid); + } finally { release(); } +}); + +test("serve status clears retired new-protocol metadata despite a recycled live pid", (t) => { + const dir = temporary(t), lock = join(dir, "serve.lock"); + mkdirSync(lock); + api.atomicJson(join(lock, "owner.json"), { protocol: "flock-v1", pid: process.pid, startedAt: 1, token: "retired" }); + writeFileSync(api.daemonSocket(dir), "retired socket"); + assert.equal(api.daemonStatus(dir).running, false); + assert.equal(existsSync(lock), false); + assert.equal(existsSync(api.daemonSocket(dir)), false); +}); + test("serve lifecycle identifies a live daemon by its recorded argv", async (t) => { const dir = temporary(t); // A real child whose argv ends in `serve`, recorded verbatim in owner.json diff --git a/prebuilt/native/darwin-universal/aiden-lease-flock b/prebuilt/native/darwin-universal/aiden-lease-flock new file mode 100755 index 000000000..434949129 Binary files /dev/null and b/prebuilt/native/darwin-universal/aiden-lease-flock differ diff --git a/prebuilt/native/darwin-universal/manifest.json b/prebuilt/native/darwin-universal/manifest.json index 62fb1b767..c28189a61 100644 --- a/prebuilt/native/darwin-universal/manifest.json +++ b/prebuilt/native/darwin-universal/manifest.json @@ -6,6 +6,10 @@ "sha256": "72f46591d67614b8125feb58a0d8ac11e794ad83ae917076bb135a49396cb7be", "source": "2d0dae7552acd391ea02189cbdcc016f04790032116e2a2c82cf15e6985178ca" }, + "lease-flock": { + "sha256": "a9dd128955b87813b235edf08e8fe23e3e307ed2300cf78f09ffb3523a8352be", + "source": "cde095d4e2059b8cc252c2f63701ed279f84a6f909c7842bc36c35cdd87a0dcc" + }, "subagent-run-store": { "sha256": "f7b4c84443e2b443f59b315fdd892b89d0f21ef35c8709c563a6070337b18988", "source": "bc81557e2b452e62b924af03d61aa4d561b0a7b3e985b17bc7d6b1e4e2b5f989" diff --git a/prebuilt/native/linux-arm64/aiden-lease-flock b/prebuilt/native/linux-arm64/aiden-lease-flock new file mode 100755 index 000000000..85d543552 Binary files /dev/null and b/prebuilt/native/linux-arm64/aiden-lease-flock differ diff --git a/prebuilt/native/linux-arm64/manifest.json b/prebuilt/native/linux-arm64/manifest.json index baaeddfb2..c45fb2180 100644 --- a/prebuilt/native/linux-arm64/manifest.json +++ b/prebuilt/native/linux-arm64/manifest.json @@ -6,6 +6,10 @@ "sha256": "1832482e431808d4fad7d2ce9f27b554caca716ffc44cdc43679976f367142e4", "source": "2d0dae7552acd391ea02189cbdcc016f04790032116e2a2c82cf15e6985178ca" }, + "lease-flock": { + "sha256": "c3d953b03ae58a28a714ac80576d070b07d52b1e5f02b30a2a7525675660bba1", + "source": "cde095d4e2059b8cc252c2f63701ed279f84a6f909c7842bc36c35cdd87a0dcc" + }, "subagent-run-store": { "sha256": "fb6530205c5638dfa26621c1f3c64ce5c98100e7927ec4ca3900dd2fa6988515", "source": "bc81557e2b452e62b924af03d61aa4d561b0a7b3e985b17bc7d6b1e4e2b5f989" diff --git a/prebuilt/native/linux-x64/aiden-lease-flock b/prebuilt/native/linux-x64/aiden-lease-flock new file mode 100755 index 000000000..f0086f951 Binary files /dev/null and b/prebuilt/native/linux-x64/aiden-lease-flock differ diff --git a/prebuilt/native/linux-x64/manifest.json b/prebuilt/native/linux-x64/manifest.json index b3d688c78..d8c40e1e3 100644 --- a/prebuilt/native/linux-x64/manifest.json +++ b/prebuilt/native/linux-x64/manifest.json @@ -6,6 +6,10 @@ "sha256": "0a26da641b1f74f1af4c40dce7d2405eff8771a705eb888a8d6366aac917d95a", "source": "2d0dae7552acd391ea02189cbdcc016f04790032116e2a2c82cf15e6985178ca" }, + "lease-flock": { + "sha256": "604c1d624a183a956c1bfdc5783a860000b183072518df9e53523472071ab770", + "source": "cde095d4e2059b8cc252c2f63701ed279f84a6f909c7842bc36c35cdd87a0dcc" + }, "subagent-run-store": { "sha256": "c2663513ee239274959ad6a93d1e1542446459ee4d427b425ac18acdc82fcf44", "source": "bc81557e2b452e62b924af03d61aa4d561b0a7b3e985b17bc7d6b1e4e2b5f989" diff --git a/scripts/build-lease-flock.mjs b/scripts/build-lease-flock.mjs new file mode 100644 index 000000000..6665d7ca5 --- /dev/null +++ b/scripts/build-lease-flock.mjs @@ -0,0 +1,23 @@ +/* global console, process */ + +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { buildNativeCExecutable } from "./native-c-build-core.mjs"; + +const executeFile = promisify(execFile); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +if (process.platform !== "darwin" && process.platform !== "linux") { + console.log("Skipping the lease flock helper build on this platform."); + process.exit(0); +} +const output = path.join(repositoryRoot, "build/native/aiden-lease-flock"); +await buildNativeCExecutable({ + executeFile, + repositoryRoot, + source: path.join(repositoryRoot, "native/lease-flock/main.c"), + output, + testing: false, +}); +console.log(`Built ${path.relative(repositoryRoot, output)}`); diff --git a/scripts/build-native-c-helpers.mjs b/scripts/build-native-c-helpers.mjs index 0ee6f50fc..57ee02e69 100644 --- a/scripts/build-native-c-helpers.mjs +++ b/scripts/build-native-c-helpers.mjs @@ -18,6 +18,7 @@ export const NATIVE_C_HELPER_BUILDERS = Object.freeze([ "build-worktree-remover.mjs", "build-worktree-file-io.mjs", "build-bot-inbox-writer.mjs", + "build-lease-flock.mjs", "build-subagent-run-store.mjs", "build-subagent-file-mutator.mjs", "build-subagent-shell-runner.mjs", diff --git a/scripts/ci-test-registry.json b/scripts/ci-test-registry.json index 5e0993d91..4302b1aa2 100644 --- a/scripts/ci-test-registry.json +++ b/scripts/ci-test-registry.json @@ -335,6 +335,7 @@ "vcc-build", "worktree-remover-build", "bot-inbox-writer-build", + "lease-flock-build", "bot-inbox-writer-test-build", "subagent-run-store-build", "subagent-run-store-test-build", @@ -639,7 +640,8 @@ "name": "renderer-other", "description": "Renderer, Telegram, scripts, onboarding, and UI-adjacent tests.", "prerequisites": [ - "worktree-remover-build" + "worktree-remover-build", + "lease-flock-build" ], "files": [ "renderer/components/settings/harness-runtime-section.test.tsx", @@ -1088,6 +1090,18 @@ ], "reason": "Native worktree remover test binary." }, + { + "id": "lease-flock-build", + "for": [ + "runtime-subagents" + ], + "command": [ + "npm", + "run", + "build:lease-flock" + ], + "reason": "Bot profile lock tests need the native flock helper." + }, { "id": "bot-inbox-writer-build", "for": [ diff --git a/scripts/ci-test-registry.mjs b/scripts/ci-test-registry.mjs index 5b78dd271..64fe5609e 100644 --- a/scripts/ci-test-registry.mjs +++ b/scripts/ci-test-registry.mjs @@ -263,6 +263,7 @@ function collectDirectTestFiles(segment, scriptName) { "node scripts/build-subagent-shell-runner.mjs", "node scripts/build-subagent-shell-runner.mjs --test", "node scripts/build-bot-inbox-writer.mjs", + "node scripts/build-lease-flock.mjs", "node scripts/build-bot-inbox-writer.mjs --test", // Linux-only helpers built by test:linux-native, which a workflow runs. "node scripts/build-global-shortcuts-portal.mjs", diff --git a/scripts/ci-test-registry.test.mjs b/scripts/ci-test-registry.test.mjs index 075fd4a2a..55fd768d5 100644 --- a/scripts/ci-test-registry.test.mjs +++ b/scripts/ci-test-registry.test.mjs @@ -34,6 +34,18 @@ test("CI registry assigns the complete deduplicated serial test union", () => { } }); +test("Bot lease consumers stage the native guard before their CI lanes", () => { + const lanes = new Map(readRegistry().lanes.map((lane) => [lane.name, lane])); + for (const [name, consumer] of [ + ["runtime-subagents", "main/services/bot-runtime/harness-host.test.ts"], + ["renderer-other", "main/services/telegram/bot-reply-outbox.test.ts"], + ]) { + const lane = lanes.get(name); + assert.ok(lane?.files.includes(consumer), `${consumer} must be assigned to ${name}`); + assert.ok(lane.prerequisites.includes("lease-flock-build"), `${name} must stage the guard helper`); + } +}); + test("malformed npm multi-script invocations fail closed", () => { const packageManifest = structuredClone(readPackageManifest()); packageManifest.scripts["test:serial"] = packageManifest.scripts["test:serial"].replace( diff --git a/scripts/native-helpers.mjs b/scripts/native-helpers.mjs index 338cfe664..078a713d7 100644 --- a/scripts/native-helpers.mjs +++ b/scripts/native-helpers.mjs @@ -1,7 +1,6 @@ /* global Buffer, process */ /** - * Shared helpers for the four Aiden native binaries (worktree-remover, - * subagent-run-store, subagent-shell-runner, subagent-file-mutator): + * Shared helpers for the Aiden native binaries: * platform/arch target naming and cheap binary-architecture verification so a * prebuilt directory can never silently install the wrong architecture. */ @@ -12,6 +11,7 @@ import { join } from "node:path"; export const NATIVE_HELPERS = [ "worktree-remover", + "lease-flock", "subagent-run-store", "subagent-shell-runner", "subagent-file-mutator", diff --git a/scripts/run-macos-distribution.test.mjs b/scripts/run-macos-distribution.test.mjs index c08b2ce38..c1555e8c4 100644 --- a/scripts/run-macos-distribution.test.mjs +++ b/scripts/run-macos-distribution.test.mjs @@ -160,6 +160,7 @@ test("release artifact configuration uses stable GitHub-safe names", async () => "Aiden Foundation Models Helper.app", "aiden-worktree-remover", "aiden-bot-inbox-writer", + "aiden-lease-flock", "aiden-subagent-run-store", "aiden-subagent-file-mutator", "aiden-subagent-shell-runner", diff --git a/scripts/sign-macos.mjs b/scripts/sign-macos.mjs index a780f8def..f13abbfd3 100644 --- a/scripts/sign-macos.mjs +++ b/scripts/sign-macos.mjs @@ -27,6 +27,7 @@ export const COMPUTER_USE_ENTITLEMENTS = path.resolve( const MINIMAL_ENTITLEMENT_HELPERS = Object.freeze([ "aiden-bot-inbox-writer", + "aiden-lease-flock", "aiden-subagent-file-mutator", "aiden-subagent-shell-runner", "aiden-subagent-run-store", diff --git a/scripts/sign-macos.test.mjs b/scripts/sign-macos.test.mjs index f610e892a..be55ebd61 100644 --- a/scripts/sign-macos.test.mjs +++ b/scripts/sign-macos.test.mjs @@ -39,6 +39,11 @@ test("mac signing hook assigns minimal entitlements to privileged and standalone .entitlements, COMPUTER_USE_ENTITLEMENTS, ); + assert.equal( + options.optionsForFile(path.join(app, "Contents", "Helpers", "aiden-lease-flock")) + .entitlements, + COMPUTER_USE_ENTITLEMENTS, + ); assert.equal( options.optionsForFile(path.join(app, "Contents", "Helpers", "aiden-subagent-run-store")) .entitlements, diff --git a/scripts/vendor-cua-driver.test.mjs b/scripts/vendor-cua-driver.test.mjs index e7f06cd93..0011cab7e 100644 --- a/scripts/vendor-cua-driver.test.mjs +++ b/scripts/vendor-cua-driver.test.mjs @@ -1,4 +1,4 @@ -/* global Buffer, process, setTimeout, URL */ +/* global Buffer, process, URL */ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; @@ -6,6 +6,10 @@ import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { + clearTimeout as nativeClearTimeout, + setTimeout as nativeSetTimeout, +} from "node:timers"; import { gzipSync } from "node:zlib"; import { assertArchiveListing, @@ -74,7 +78,7 @@ async function waitForProcessExit(pid, timeoutMs = 2_000) { if (error?.code === "ESRCH") return; throw error; } - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => nativeSetTimeout(resolve, 20)); } throw new Error(`Process ${pid} did not exit.`); } @@ -88,7 +92,7 @@ async function waitForPidFile(file, timeoutMs = 2_000) { } catch (error) { if (error?.code !== "ENOENT") throw error; } - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => nativeSetTimeout(resolve, 20)); } throw new Error(`Process id was not written to ${file}.`); } @@ -388,25 +392,29 @@ test("optional Sigstore verification rejects an unpinned bundle before running c test( "bounded vendor commands kill descendants that retain an exited leader's stdio", { skip: process.platform === "win32" }, - async () => { + async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "aiden-cua-vendor-tree-")); const descendantPidPath = path.join(root, "descendant.pid"); const descendantSource = [ "process.on('SIGTERM', () => {});", + "require('node:fs').writeFileSync(process.argv[1], String(process.pid));", "setInterval(() => {}, 1000);", ].join(""); const leaderSource = [ "const { spawn } = require('node:child_process');", - "const { writeFileSync } = require('node:fs');", - "const descendant = spawn(process.execPath, ['-e', process.argv[1]], {", + "setTimeout(() => {", + "const descendant = spawn(process.execPath, ['-e', process.argv[1], process.argv[2]], {", " stdio: ['ignore', 'inherit', 'inherit']", "});", - "writeFileSync(process.argv[2], String(descendant.pid));", "descendant.unref();", + "}, 350);", ].join(""); + let timedOut; + let descendantPid; + let deadlineTriggered = false; try { - const startedAt = Date.now(); - await assert.rejects( + t.mock.timers.enable({ apis: ["setTimeout"] }); + timedOut = assert.rejects( runBoundedCommand( process.execPath, ["-e", leaderSource, descendantSource, descendantPidPath], @@ -414,11 +422,39 @@ test( ), /timed out/, ); + timedOut.catch(() => {}); + descendantPid = await waitForPidFile(descendantPidPath); + const startedAt = Date.now(); + deadlineTriggered = true; + t.mock.timers.tick(250); + let settleTimer; + try { + await Promise.race([ + timedOut, + new Promise((resolve) => { + settleTimer = nativeSetTimeout(resolve, 250); + }), + ]); + } finally { + nativeClearTimeout(settleTimer); + } + t.mock.timers.tick(200); + await timedOut; assert.ok(Date.now() - startedAt < 1_500, "the bounded runner should settle promptly"); - const descendantPid = Number(await readFile(descendantPidPath, "utf8")); - assert.ok(Number.isSafeInteger(descendantPid) && descendantPid > 1); await waitForProcessExit(descendantPid); } finally { + if (timedOut && !deadlineTriggered) { + t.mock.timers.tick(450); + await timedOut.catch(() => {}); + } + t.mock.timers.reset(); + if (descendantPid) { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // Timeout cleanup normally removes the complete process group. + } + } await rm(root, { recursive: true, force: true }); } }, diff --git a/scripts/verify-linux-package.mjs b/scripts/verify-linux-package.mjs index 3ed39568b..e5779a498 100644 --- a/scripts/verify-linux-package.mjs +++ b/scripts/verify-linux-package.mjs @@ -26,6 +26,7 @@ const REQUIRED_HELPERS = Object.freeze([ "aiden-secret-service-authority", "aiden-worktree-remover", "aiden-bot-inbox-writer", + "aiden-lease-flock", "aiden-subagent-run-store", "aiden-subagent-file-mutator", "aiden-subagent-shell-runner", diff --git a/scripts/verify-linux-package.test.mjs b/scripts/verify-linux-package.test.mjs index a1c595ba4..5c5108539 100644 --- a/scripts/verify-linux-package.test.mjs +++ b/scripts/verify-linux-package.test.mjs @@ -22,7 +22,7 @@ test("Linux package targets common distro formats and includes native helpers", assert.doesNotThrow(() => assertLinuxBuildConfiguration(packageJson)); }); -test("Linux package requires the Bot inbox helper and shared Generative UI libraries", () => { +test("Linux package requires its lease and Bot inbox helpers and shared Generative UI libraries", () => { const missingHelper = JSON.parse(JSON.stringify(packageJson)); missingHelper.build.linux.extraFiles = missingHelper.build.linux.extraFiles.filter( (entry) => !String(entry.to).includes("aiden-bot-inbox-writer"), @@ -32,6 +32,15 @@ test("Linux package requires the Bot inbox helper and shared Generative UI libra /aiden-bot-inbox-writer/u, ); + const missingLeaseHelper = JSON.parse(JSON.stringify(packageJson)); + missingLeaseHelper.build.linux.extraFiles = missingLeaseHelper.build.linux.extraFiles.filter( + (entry) => !String(entry.to).includes("aiden-lease-flock"), + ); + assert.throws( + () => assertLinuxBuildConfiguration(missingLeaseHelper), + /aiden-lease-flock/u, + ); + const missingLibraries = JSON.parse(JSON.stringify(packageJson)); missingLibraries.build.extraResources = missingLibraries.build.extraResources.filter( (entry) => entry.from !== "resources/generative-ui", diff --git a/scripts/verify-macos-package.mjs b/scripts/verify-macos-package.mjs index c652e3cfb..6cbc0f6a0 100644 --- a/scripts/verify-macos-package.mjs +++ b/scripts/verify-macos-package.mjs @@ -87,6 +87,7 @@ const FORM_FILL_HELPER_BUNDLE_ID = "com.sambitcreate.aiden-agent.cua-s1-forms-he const FORM_FILL_HELPER_EXECUTABLE = "aiden-cua-s1-forms-helper"; const WORKTREE_FILE_IO_EXECUTABLE = "aiden-worktree-file-io"; const BOT_INBOX_WRITER_EXECUTABLE = "aiden-bot-inbox-writer"; +const LEASE_FLOCK_EXECUTABLE = "aiden-lease-flock"; const SUBAGENT_RUN_STORE_EXECUTABLE = "aiden-subagent-run-store"; const SUBAGENT_FILE_MUTATOR_EXECUTABLE = "aiden-subagent-file-mutator"; const SUBAGENT_SHELL_RUNNER_EXECUTABLE = "aiden-subagent-shell-runner"; @@ -808,6 +809,7 @@ export async function verifyMacPackage(appPath) { const worktreeRemover = path.join(paths.app, "Contents", "Helpers", WORKTREE_REMOVER_EXECUTABLE); const worktreeFileIo = path.join(paths.app, "Contents", "Helpers", WORKTREE_FILE_IO_EXECUTABLE); const botInboxWriter = path.join(paths.app, "Contents", "Helpers", BOT_INBOX_WRITER_EXECUTABLE); + const leaseFlock = path.join(paths.app, "Contents", "Helpers", LEASE_FLOCK_EXECUTABLE); const subagentRunStore = path.join( paths.app, "Contents", @@ -858,6 +860,7 @@ export async function verifyMacPackage(appPath) { worktreeRemover, worktreeFileIo, botInboxWriter, + leaseFlock, subagentRunStore, subagentFileMutator, subagentShellRunner, @@ -883,6 +886,7 @@ export async function verifyMacPackage(appPath) { assertComputerUseExecutableMode((await lstat(worktreeRemover)).mode, worktreeRemover); assertComputerUseExecutableMode((await lstat(worktreeFileIo)).mode, worktreeFileIo); assertComputerUseExecutableMode((await lstat(botInboxWriter)).mode, botInboxWriter); + assertComputerUseExecutableMode((await lstat(leaseFlock)).mode, leaseFlock); assertComputerUseExecutableMode((await lstat(subagentRunStore)).mode, subagentRunStore); assertComputerUseExecutableMode((await lstat(subagentFileMutator)).mode, subagentFileMutator); assertComputerUseExecutableMode((await lstat(subagentShellRunner)).mode, subagentShellRunner); @@ -969,6 +973,10 @@ export async function verifyMacPackage(appPath) { identifier: BOT_INBOX_WRITER_EXECUTABLE, teamId: AIDEN_SIGNING_TEAM_ID, }); + await verifySignature(leaseFlock, { + identifier: LEASE_FLOCK_EXECUTABLE, + teamId: AIDEN_SIGNING_TEAM_ID, + }); await verifySignature(subagentRunStore, { identifier: SUBAGENT_RUN_STORE_EXECUTABLE, teamId: AIDEN_SIGNING_TEAM_ID, @@ -1007,6 +1015,7 @@ export async function verifyMacPackage(appPath) { worktreeRemover, worktreeFileIo, botInboxWriter, + leaseFlock, subagentRunStore, subagentFileMutator, subagentShellRunner, @@ -1028,6 +1037,7 @@ export async function verifyMacPackage(appPath) { await verifyUniversalMacOSHelper(worktreeRemover, "Managed worktree remover"); await verifyUniversalMacOSHelper(worktreeFileIo, "Managed worktree file I/O"); await verifyUniversalMacOSHelper(botInboxWriter, "Bot inbox writer"); + await verifyUniversalMacOSHelper(leaseFlock, "Lease lock helper"); await verifyUniversalMacOSHelper(subagentRunStore, "Private subagent run store"); await verifyUniversalMacOSHelper(subagentFileMutator, "Subagent file mutator"); await verifyUniversalMacOSHelper(subagentShellRunner, "Subagent shell runner"); @@ -1048,6 +1058,7 @@ export async function verifyMacPackage(appPath) { assertMinimalComputerUseEntitlements(await readEntitlements(worktreeRemover)); assertMinimalComputerUseEntitlements(await readEntitlements(worktreeFileIo)); assertMinimalComputerUseEntitlements(await readEntitlements(botInboxWriter)); + assertMinimalComputerUseEntitlements(await readEntitlements(leaseFlock)); assertMinimalComputerUseEntitlements(await readEntitlements(subagentRunStore)); assertMinimalComputerUseEntitlements(await readEntitlements(subagentFileMutator)); assertMinimalComputerUseEntitlements(await readEntitlements(subagentShellRunner)); diff --git a/tests/e2e/chat-top-bar.spec.ts b/tests/e2e/chat-top-bar.spec.ts index c19c2969f..b624663c6 100644 --- a/tests/e2e/chat-top-bar.spec.ts +++ b/tests/e2e/chat-top-bar.spec.ts @@ -342,6 +342,7 @@ test.describe("folder workspaces", () => { await page.locator("[data-branch-name-row]").click(); await page.getByRole("textbox", { name: /Branch name/u }).fill("aiden/taken"); await page.keyboard.press("Enter"); + await expect(page.locator("[data-branch-name-row]")).toContainText("aiden/taken"); await page.keyboard.press("Escape"); await page.locator("textarea").fill("Retry after leaving");