fix: reduce opencode session list churn (#1478)

* fix: reduce opencode session list churn

* fix: make bun-tmp-janitor cross-platform and move to process-level boot

- Extend platform support from Linux-only to Linux + macOS (win32 skipped
  since opencode ships no Windows binary and the kernel disallows unlinking
  mapped files there)
- Use os.tmpdir() instead of hardcoded /tmp to handle macOS $TMPDIR paths
- Extend file pattern from \.so to \.(so|dylib) to cover macOS dylib leaks
- Move startBunTmpJanitor() from ensureLifecycleWorker() (per-project) to
  the process-level boot in registerStart() immediately after register(),
  where the single-instance contract is already in force
- Drop the project-observer-bound onSweep closure that incorrectly attributed
  janitor health to whichever project happened to start first; replaced with
  a simple stderr warn on errors (no project context needed for a process-wide
  sweep of /tmp)
- Move stopBunTmpJanitor() into the SIGINT/SIGTERM shutdown handler in
  registerStart() alongside stopAllLifecycleWorkers()
- Remove startBunTmpJanitor/stopBunTmpJanitor from lifecycle-service.ts
  entirely; lifecycle workers have no business knowing about a process-wide
  OS resource

* fix(opencode): address PR #1478 review (TMPDIR isolation, shared cache, janitor cleanup)

Implements all seven findings from the PR #1478 review:

Core / agent-opencode:
- New @aoagents/ao-core/opencode-shared module owns the single TTL cache
  + in-flight dedup for 'opencode session list' (was duplicated across
  core and the plugin, doubling spawns per poll cycle).
- TTL dropped from 3s to 500ms so the send-confirmation loop's
  updatedAt > baselineUpdatedAt delivery signal can actually fire.
- New invalidateOpenCodeSessionListCache() called by deleteOpenCodeSession
  so reuse / remap / restore code paths cannot observe a deleted id.
- New getOpenCodeChildEnv() / getOpenCodeTmpDir(): every opencode child
  spawned by core, the plugin, or the agent runtime points TMPDIR/TMP/TEMP
  at ~/.agent-orchestrator/.bun-tmp. Bounds the janitor's blast radius
  to AO-owned files even on shared hosts.

CLI janitor:
- Sweeps only the AO-owned tmp dir (not the system /tmp).
- Filters synchronously before spawning per-entry stat/unlink work.
- stopBunTmpJanitor() is now async and awaits any in-flight sweep so
  SIGTERM cannot exit while unlink() is mid-flight; start.ts shutdown
  handler awaits it.
- onSweep callback in start.ts now logs successful reclaims, not just
  errors, so operators can confirm the janitor is doing useful work.

Tests:
- packages/core/__tests__/opencode-shared.test.ts (TTL contract,
  TMPDIR location, env merge semantics).
- packages/cli/__tests__/lib/bun-tmp-janitor.test.ts (sweep behavior,
  stop-awaits-in-flight, pattern matching, missing-dir tolerance).

* chore: remove review postmortem artifact

* fix(cli): remove start command non-null assertions
This commit is contained in:
Madhav Kumar 2026-04-29 01:24:55 +05:30 committed by GitHub
parent 5f671ee3c7
commit 4701122342
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 700 additions and 70 deletions

View File

@ -0,0 +1,37 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
"@aoagents/ao-plugin-agent-opencode": patch
---
opencode: bound /tmp blast radius and consolidate session-list cache
Addresses review feedback on PR #1478:
- **TMPDIR isolation.** Every `opencode` child we spawn now points at
`~/.agent-orchestrator/.bun-tmp/` via `TMPDIR`/`TMP`/`TEMP`. Bun's
embedded shared-library extraction lands there instead of the system
`/tmp`, so the cli janitor only ever sweeps AO-owned files. Other
users' or other applications' Bun artifacts on a shared host can no
longer be touched by the regex.
- **Single shared session-list cache.** Core and the agent-opencode
plugin previously kept independent caches; per poll cycle the system
spawned at least two `opencode session list` processes instead of
one. Both consumers now use the shared cache exported from
`@aoagents/ao-core` (`getCachedOpenCodeSessionList`).
- **TTL no longer covers the send-confirmation loop.** The cache TTL
dropped from 3s to 500ms so the
`updatedAt > baselineUpdatedAt` delivery signal in
`sendWithConfirmation` actually fires. Concurrent callers still
share the in-flight promise.
- **Delete invalidates the cache.** `deleteOpenCodeSession` now calls
`invalidateOpenCodeSessionListCache()` on success so reuse, remap,
and restore code paths cannot observe a deleted session id within
the TTL window.
- **Janitor reliability.** `sweepOnce` now filters synchronously
before allocating per-file promises (matters on hosts with thousands
of `/tmp` entries), and `stopBunTmpJanitor()` is now async and awaits
any in-flight sweep so SIGTERM cannot exit while `unlink` is mid-flight.
- **Janitor observability.** The sweep callback in `ao start` now logs
successful reclaims, not just errors, so operators can confirm the
janitor is doing useful work.

View File

@ -0,0 +1,122 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, statSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type * as AoCore from "@aoagents/ao-core";
// We point the shared module at a fresh temp dir per test by mocking the
// AO base path resolver. The janitor pulls the dir from
// @aoagents/ao-core's getOpenCodeTmpDir.
let mockedDir = "";
vi.mock("@aoagents/ao-core", async () => {
const actual = await vi.importActual<typeof AoCore>("@aoagents/ao-core");
return {
...actual,
getOpenCodeTmpDir: () => mockedDir,
};
});
import {
isBunTmpJanitorRunning,
startBunTmpJanitor,
stopBunTmpJanitor,
} from "../../src/lib/bun-tmp-janitor.js";
const PATTERN_NAME = ".fcb8efb7fbaad77d-00000000.so";
function setMtime(path: string, ageMs: number): void {
const t = (Date.now() - ageMs) / 1000;
utimesSync(path, t, t);
}
describe("bun-tmp-janitor", () => {
beforeEach(() => {
mockedDir = mkdtempSync(join(tmpdir(), "ao-bun-janitor-test-"));
});
afterEach(async () => {
await stopBunTmpJanitor();
});
it("sweeps matching files older than ageMs in the AO-owned dir only", async () => {
const oldFile = join(mockedDir, PATTERN_NAME);
const youngFile = join(mockedDir, ".aaaaaaaa-bbbbbbbb.so");
const unrelated = join(mockedDir, "regular-file.txt");
writeFileSync(oldFile, "x".repeat(1024));
writeFileSync(youngFile, "x");
writeFileSync(unrelated, "x");
setMtime(oldFile, 120_000); // 2 minutes old
const sweeps: { removed: number; freedBytes: number; errors: number }[] = [];
startBunTmpJanitor({
intervalMs: 60_000,
ageMs: 60_000,
onSweep: (r) => sweeps.push(r),
});
// Wait for the immediate sweep to complete.
await stopBunTmpJanitor();
expect(sweeps.length).toBe(1);
expect(sweeps[0]?.removed).toBe(1);
expect(sweeps[0]?.freedBytes).toBe(1024);
expect(existsSync(oldFile)).toBe(false);
expect(existsSync(youngFile)).toBe(true);
expect(existsSync(unrelated)).toBe(true);
});
it("treats a missing AO tmp dir as empty (no error)", async () => {
mockedDir = join(tmpdir(), `ao-bun-janitor-missing-${Date.now()}`);
expect(existsSync(mockedDir)).toBe(false);
const sweeps: { removed: number; freedBytes: number; errors: number }[] = [];
startBunTmpJanitor({ onSweep: (r) => sweeps.push(r) });
await stopBunTmpJanitor();
// No callback fires when removed=0 and errors=0.
expect(sweeps).toEqual([]);
});
it("stopBunTmpJanitor awaits the in-flight sweep", async () => {
// Drop a matching file so the immediate sweep does real work.
const path = join(mockedDir, PATTERN_NAME);
writeFileSync(path, "x");
setMtime(path, 120_000);
startBunTmpJanitor({ ageMs: 60_000 });
expect(isBunTmpJanitorRunning()).toBe(true);
await stopBunTmpJanitor();
// After awaiting stop, the sweep must have run to completion: the
// file must be gone, and the timer cleared.
expect(isBunTmpJanitorRunning()).toBe(false);
expect(existsSync(path)).toBe(false);
});
it("does not double-start", async () => {
const a = startBunTmpJanitor();
const b = startBunTmpJanitor();
expect(a).toBe(true);
expect(b).toBe(false);
await stopBunTmpJanitor();
});
it("ignores files that do not match the Bun tmp library pattern", async () => {
const matching = join(mockedDir, ".1234567890abcdef-12345678.dylib");
const nonMatching = join(mockedDir, "libopentui.so");
writeFileSync(matching, "x");
writeFileSync(nonMatching, "x");
setMtime(matching, 120_000);
setMtime(nonMatching, 120_000);
startBunTmpJanitor({ ageMs: 60_000 });
await stopBunTmpJanitor();
expect(existsSync(matching)).toBe(false);
expect(existsSync(nonMatching)).toBe(true);
// Sanity: stat works (file exists), proves we did not unlink it.
expect(statSync(nonMatching).size).toBeGreaterThan(0);
});
});

View File

@ -48,6 +48,7 @@ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import { exec, execSilent, git } from "../lib/shell.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { ensureLifecycleWorker, stopAllLifecycleWorkers } from "../lib/lifecycle-service.js";
import { startBunTmpJanitor, stopBunTmpJanitor } from "../lib/bun-tmp-janitor.js";
import {
findWebDir,
buildDashboardEnv,
@ -1636,6 +1637,10 @@ export function registerStart(program: Command): void {
// Non-TTY callers (scripts/agents) keep the old "AO is already
// running" message and do NOT mutate config behind the user's back.
if (running && projectArgIsUrlOrPath && isHumanCaller()) {
const requestedProjectArg = projectArg;
if (!requestedProjectArg) {
throw new Error("Expected project path or URL argument");
}
// Always register against the GLOBAL config — never the cwd's
// local config. Cross-project visibility lives in the global
// registry, and addProjectToConfig only routes to global when
@ -1646,9 +1651,9 @@ export function registerStart(program: Command): void {
: loadConfig();
let existingId: string | null = null;
if (isRepoUrl(projectArg!)) {
if (isRepoUrl(requestedProjectArg)) {
try {
const parsed = parseRepoUrl(projectArg!);
const parsed = parseRepoUrl(requestedProjectArg);
for (const [id, p] of Object.entries(globalCfg.projects)) {
if (p.repo === parsed.ownerRepo) {
existingId = id;
@ -1660,7 +1665,7 @@ export function registerStart(program: Command): void {
}
} else {
const resolvedPath = resolve(
projectArg!.replace(/^~/, process.env["HOME"] || ""),
requestedProjectArg.replace(/^~/, process.env["HOME"] || ""),
);
let canonicalTarget: string;
try {
@ -1697,14 +1702,14 @@ export function registerStart(program: Command): void {
let resolvedId: string;
if (existingId) {
resolvedId = existingId;
} else if (isRepoUrl(projectArg!)) {
} else if (isRepoUrl(requestedProjectArg)) {
// Clone + register inline. We DO NOT call handleUrlStart —
// that helper writes a legacy wrapped (`projects:`) local
// config that the new resolver rejects, requiring a repair
// pass after the fact. Instead, we write a flat local config
// here so the global registry + repo can be loaded cleanly
// on the very first read.
const parsed = parseRepoUrl(projectArg!);
const parsed = parseRepoUrl(requestedProjectArg);
console.log(
chalk.bold.cyan(`\n Cloning ${parsed.ownerRepo} (${parsed.host})\n`),
);
@ -1736,7 +1741,7 @@ export function registerStart(program: Command): void {
throw new Error(
`Repository "${parsed.ownerRepo}" appears to be empty (no commits or refs).\n` +
` AO needs at least one commit on the default branch to spawn an orchestrator.\n` +
` Push an initial commit, then re-run \`ao start ${projectArg}\`.`,
` Push an initial commit, then re-run \`ao start ${requestedProjectArg}\`.`,
);
}
@ -1775,7 +1780,7 @@ export function registerStart(program: Command): void {
}
} else {
const resolvedPath = resolve(
projectArg!.replace(/^~/, process.env["HOME"] || ""),
requestedProjectArg.replace(/^~/, process.env["HOME"] || ""),
);
resolvedId = await addProjectToConfig(globalCfg, resolvedPath);
}
@ -2221,6 +2226,25 @@ export function registerStart(program: Command): void {
});
unlockStartup();
// Start the Bun-extracted /tmp/.*.{so,dylib} janitor once per AO
// process. Single-instance is enforced by running.json + the
// startup lock above, so this call site is reached at most once
// per process. The janitor uses an unref'd interval timer, so it
// does not keep the event loop alive on its own and dies with the
// process on SIGTERM/SIGINT.
startBunTmpJanitor({
onSweep: ({ removed, freedBytes, errors }) => {
if (removed > 0) {
console.info(
`[bun-tmp-janitor] reclaimed ${removed} file(s) / ${freedBytes} bytes`,
);
}
if (errors > 0) {
console.warn(`[bun-tmp-janitor] sweep had ${errors} error(s)`);
}
},
});
// Install shutdown handlers so Ctrl+C and `ao stop` (which sends
// SIGTERM) perform a full graceful shutdown: kill sessions, record
// last-stop state for restore, unregister, then exit.
@ -2290,11 +2314,22 @@ export function registerStart(program: Command): void {
} catch {
// Best-effort — always exit even if cleanup fails
}
try {
// Await any in-flight sweep so shutdown does not exit while
// unlink() calls are still mid-flight against the filesystem.
await stopBunTmpJanitor();
} catch {
// Best-effort cleanup.
}
process.exit(exitCode);
})();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
process.once("SIGINT", (sig) => {
void shutdown(sig);
});
process.once("SIGTERM", (sig) => {
void shutdown(sig);
});
} catch (err) {
if (err instanceof Error) {
console.error(chalk.red("\nError:"), err.message);

View File

@ -0,0 +1,145 @@
import { readdir, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { getOpenCodeTmpDir } from "@aoagents/ao-core";
// Bun-bundled binaries (opencode, etc.) extract embedded shared libraries to
// `TMPDIR` on startup and never unlink them on exit — this is a known upstream
// Bun bug that leaks ~4.3 MB per process invocation. Files look like
// `.{16hex}-{8hex}.{so|dylib}` (e.g. `.fcb8efb7fbaad77d-00000000.so`).
//
// We point every `opencode` child we spawn at an AO-owned subdirectory via
// `TMPDIR` (see `getOpenCodeChildEnv` in `@aoagents/ao-core`). The janitor
// then sweeps **only that directory**, which keeps the blast radius bounded:
// no other user's or other application's Bun artifacts can ever be touched
// even if AO runs as root on a shared host.
//
// Deleting these files is safe even while a live process has them mmap'd: on
// POSIX systems, `unlink` removes the directory entry but the kernel keeps
// the inode alive until the last mapping is torn down, at which point the
// space is reclaimed. For already-exited processes the unlink frees disk
// immediately. Windows does not allow unlinking mapped files, and opencode
// does not ship a Windows binary, so the janitor is a no-op there.
//
// This janitor runs once per `ao start` process and sweeps matching files
// older than `ageMs` at every interval.
const BUN_TMP_LIB_PATTERN = /^\.[0-9a-f]{8,}-[0-9a-f]{6,}\.(so|dylib)$/i;
const DEFAULT_INTERVAL_MS = 60_000;
const DEFAULT_AGE_MS = 60_000;
export interface BunTmpJanitorOptions {
intervalMs?: number;
ageMs?: number;
onSweep?: (result: { removed: number; freedBytes: number; errors: number }) => void;
}
let timer: NodeJS.Timeout | null = null;
let inFlightTick: Promise<void> | null = null;
async function sweepOnce(
dir: string,
ageMs: number,
): Promise<{ removed: number; freedBytes: number; errors: number }> {
let removed = 0;
let freedBytes = 0;
let errors = 0;
let entries: string[];
try {
entries = await readdir(dir);
} catch {
// Directory may not exist yet (no opencode child has run). That is not
// an error condition — there is nothing to sweep.
return { removed, freedBytes, errors: 0 };
}
// Filter synchronously *before* spawning per-entry stat/unlink work. On a
// host with thousands of /tmp entries this avoids allocating one promise
// per file we are about to discard. (Belt-and-suspenders: TMPDIR isolation
// already bounds the directory contents to AO's own children.)
const matches = entries.filter((name) => BUN_TMP_LIB_PATTERN.test(name));
if (matches.length === 0) {
return { removed, freedBytes, errors: 0 };
}
const cutoff = Date.now() - ageMs;
await Promise.all(
matches.map(async (name) => {
const path = join(dir, name);
try {
const st = await stat(path);
if (!st.isFile() || st.mtimeMs > cutoff) return;
await unlink(path);
removed += 1;
freedBytes += st.size;
} catch {
// File may have been deleted by another sweeper, or stat raced
// with an unlink, or we lack permission. Best-effort — don't throw.
errors += 1;
}
}),
);
return { removed, freedBytes, errors };
}
export function startBunTmpJanitor(options: BunTmpJanitorOptions = {}): boolean {
// Windows: opencode ships no win32 binary and unlinking mapped files is
// disallowed by the kernel, so the janitor would be both unnecessary and
// potentially error-prone. Skip.
if (process.platform === "win32") return false;
if (timer) return false;
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
const ageMs = options.ageMs ?? DEFAULT_AGE_MS;
const { onSweep } = options;
const dir = getOpenCodeTmpDir();
const tick = async (): Promise<void> => {
// Single-flight: if a previous tick is still running, skip this one.
if (inFlightTick) return;
const promise = (async () => {
try {
const result = await sweepOnce(dir, ageMs);
if (onSweep && (result.removed > 0 || result.errors > 0)) {
onSweep(result);
}
} finally {
inFlightTick = null;
}
})();
inFlightTick = promise;
await promise;
};
// Run an immediate sweep to clear any backlog, then on an interval.
void tick();
timer = setInterval(() => void tick(), intervalMs);
timer.unref();
return true;
}
/**
* Stop the janitor and await any sweep currently in flight. The shutdown
* handler in `start.ts` awaits this so the process never exits while
* `unlink` calls are still mid-flight against the filesystem.
*/
export async function stopBunTmpJanitor(): Promise<void> {
if (timer) {
clearInterval(timer);
timer = null;
}
if (inFlightTick) {
try {
await inFlightTick;
} catch {
// Best-effort — don't block shutdown on an in-flight sweep error.
}
}
}
export function isBunTmpJanitorRunning(): boolean {
return timer !== null;
}

View File

@ -0,0 +1,68 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
ensureOpenCodeTmpDir,
getOpenCodeChildEnv,
getOpenCodeTmpDir,
invalidateOpenCodeSessionListCache,
resetOpenCodeSessionListCache,
OPENCODE_SESSION_LIST_CACHE_TTL_MS,
} from "../opencode-shared.js";
describe("opencode-shared", () => {
beforeEach(() => {
resetOpenCodeSessionListCache();
});
describe("OPENCODE_SESSION_LIST_CACHE_TTL_MS", () => {
it("does not cover the 500ms send-confirmation poll interval", () => {
// The send-confirmation loop in session-manager polls 6× at 500ms
// intervals (~3s total). The TTL must be ≤500ms so each loop
// iteration sees fresh data and the
// `updatedAt > baselineUpdatedAt` delivery signal can fire.
expect(OPENCODE_SESSION_LIST_CACHE_TTL_MS).toBeLessThanOrEqual(500);
expect(OPENCODE_SESSION_LIST_CACHE_TTL_MS).toBeGreaterThan(0);
});
});
describe("getOpenCodeTmpDir / ensureOpenCodeTmpDir", () => {
it("lives under the AO base dir, not the system /tmp", () => {
const dir = getOpenCodeTmpDir();
expect(dir).toMatch(/\.agent-orchestrator[\\/]\.bun-tmp$/);
expect(dir.startsWith("/tmp")).toBe(false);
});
it("creates the directory and tolerates pre-existing", () => {
const a = ensureOpenCodeTmpDir();
const b = ensureOpenCodeTmpDir();
expect(a).toBe(b);
});
});
describe("getOpenCodeChildEnv", () => {
it("sets TMPDIR/TMP/TEMP to the AO-owned dir", () => {
const env = getOpenCodeChildEnv();
const dir = getOpenCodeTmpDir();
expect(env["TMPDIR"]).toBe(dir);
expect(env["TMP"]).toBe(dir);
expect(env["TEMP"]).toBe(dir);
});
it("merges extra env vars on top, but keeps TMPDIR pointed at AO dir", () => {
const env = getOpenCodeChildEnv({ FOO: "bar" });
expect(env["FOO"]).toBe("bar");
expect(env["TMPDIR"]).toBe(getOpenCodeTmpDir());
});
it("allows extra env to override TMPDIR for explicit opt-out", () => {
const env = getOpenCodeChildEnv({ TMPDIR: "/elsewhere" });
expect(env["TMPDIR"]).toBe("/elsewhere");
});
});
describe("invalidateOpenCodeSessionListCache", () => {
it("is a no-throw on an empty cache", () => {
expect(() => invalidateOpenCodeSessionListCache()).not.toThrow();
});
});
});

View File

@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { getProjectSessionsDir, getProjectDir } from "../paths.js";
import { resetOpenCodeSessionListCache } from "../session-manager.js";
import { createInitialCanonicalLifecycle, deriveLegacyStatus } from "../lifecycle-state.js";
import { createActivitySignal } from "../activity-signal.js";
import type {
@ -364,6 +365,7 @@ export interface TestContext {
}
export function setupTestContext(): TestContext {
resetOpenCodeSessionListCache();
const originalPath = process.env.PATH;
const originalHome = process.env["HOME"];
const tmpDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`);

View File

@ -151,6 +151,16 @@ export {
parseWebhookBranchRef,
} from "./scm-webhook-utils.js";
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
export {
OPENCODE_SESSION_LIST_CACHE_TTL_MS,
getOpenCodeTmpDir,
ensureOpenCodeTmpDir,
getOpenCodeChildEnv,
getCachedOpenCodeSessionList,
invalidateOpenCodeSessionListCache,
resetOpenCodeSessionListCache,
} from "./opencode-shared.js";
export type { OpenCodeSessionListEntry } from "./opencode-shared.js";
export {
getWorkspaceAgentsMdPath,
writeWorkspaceOpenCodeAgentsMd,

View File

@ -0,0 +1,235 @@
/**
* Shared opencode-child plumbing.
*
* This module exists for two reasons:
*
* 1. **Bounded /tmp blast radius** (issue #1046, PR #1478 review).
* Bun-bundled binaries leak `.so`/`.dylib` files into the system temp
* directory and never unlink them. Rather than sweeping all of `/tmp`
* with a regex (which would touch other users' or other apps' Bun
* artifacts), we point every `opencode` child at an AO-owned temp dir
* via `TMPDIR`. The cli-side janitor then sweeps only that directory.
*
* 2. **Single shared cache for `opencode session list`.**
* Both `@aoagents/ao-core` and the `agent-opencode` plugin previously
* kept independent module-level caches. Per poll cycle the system
* therefore spawned at least two `opencode session list` processes
* instead of one. A single cache exported from core collapses them.
*/
import { execFile } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { getAoBaseDir } from "./paths.js";
import { safeJsonParse } from "./utils/validation.js";
import { asValidOpenCodeSessionId } from "./opencode-session-id.js";
const execFileAsync = promisify(execFile);
// -----------------------------------------------------------------------------
// AO-owned temp dir for opencode children
// -----------------------------------------------------------------------------
let cachedOpenCodeTmpDir: string | null = null;
/**
* Path that opencode children should treat as `TMPDIR`. Lives under the AO
* base dir so a stray sweep cannot touch unrelated files.
*/
export function getOpenCodeTmpDir(): string {
if (cachedOpenCodeTmpDir) return cachedOpenCodeTmpDir;
cachedOpenCodeTmpDir = join(getAoBaseDir(), ".bun-tmp");
return cachedOpenCodeTmpDir;
}
/**
* Best-effort: create the AO-owned temp dir. Spawn paths call this before
* launching opencode so the child sees a real directory at `TMPDIR`.
*
* Synchronous because the spawn helpers are themselves synchronous in their
* env construction. Failures are swallowed opencode will fall back to the
* OS default temp dir, which is the pre-PR behavior.
*/
export function ensureOpenCodeTmpDir(): string {
const dir = getOpenCodeTmpDir();
try {
mkdirSync(dir, { recursive: true });
} catch {
// Best-effort. If creation fails opencode still works; we just leak to
// the system temp dir as before.
}
return dir;
}
/**
* Build the env passed to every spawned `opencode` child.
*
* Setting both `TMPDIR` and `TMP`/`TEMP` covers POSIX (TMPDIR) and Windows
* fallbacks. Bun honors `TMPDIR` for its embedded shared-library extraction.
*/
export function getOpenCodeChildEnv(
extra?: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
const dir = ensureOpenCodeTmpDir();
return {
...process.env,
TMPDIR: dir,
TMP: dir,
TEMP: dir,
...extra,
};
}
// -----------------------------------------------------------------------------
// Shared `opencode session list` cache
// -----------------------------------------------------------------------------
export interface OpenCodeSessionListEntry {
id: string;
title: string;
/** Raw `updated` field as emitted by opencode (string or number). */
updated?: string | number;
/** Normalized epoch ms, parsed from `updated`. */
updatedAt?: number;
}
/**
* TTL for the `opencode session list` cache.
*
* **Why 500ms.** The send-confirmation loop in `session-manager.sendMessage`
* polls at 500ms intervals up to 6 times (~3s total). The original PR sized
* the TTL to *cover* that window (3s), which made every loop iteration return
* the same cached snapshot the `updatedAt > baselineUpdatedAt` delivery
* signal could not fire by construction. Sizing the TTL at 500ms means each
* poll iteration sees fresh data while still collapsing tight bursts (e.g.
* lifecycle poll + UI enrichment in the same tick) onto a single child.
* Concurrent callers always share the in-flight promise regardless of TTL.
*/
export const OPENCODE_SESSION_LIST_CACHE_TTL_MS = 500;
const OPENCODE_SESSION_LIST_DEFAULT_TIMEOUT_MS = 30_000;
interface OpenCodeSessionListCache {
entries: OpenCodeSessionListEntry[];
timestamp: number;
promise?: Promise<OpenCodeSessionListEntry[]>;
}
let sessionListCache: OpenCodeSessionListCache | null = null;
function parseUpdatedToEpochMs(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (/^\d+$/.test(trimmed)) {
const n = Number(trimmed);
return Number.isFinite(n) ? n : undefined;
}
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : parsed;
}
function parseSessionListStdout(stdout: string): OpenCodeSessionListEntry[] {
const parsed = safeJsonParse<unknown>(stdout);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const record = entry as Record<string, unknown>;
const id = asValidOpenCodeSessionId(record["id"]);
if (!id) return [];
const title = typeof record["title"] === "string" ? record["title"] : "";
const rawUpdated = record["updated"];
const updated =
typeof rawUpdated === "string" || typeof rawUpdated === "number"
? rawUpdated
: undefined;
const updatedAt = parseUpdatedToEpochMs(rawUpdated);
return [
{
id,
title,
...(updated !== undefined ? { updated } : {}),
...(updatedAt !== undefined ? { updatedAt } : {}),
},
];
});
}
/**
* Fetch the opencode session list, sharing both the cached snapshot and any
* in-flight request across all callers in core and plugins.
*
* Pass `forceRefresh: true` to bypass the TTL when a write is known to have
* just landed. Concurrent callers still collapse onto the in-flight promise.
*/
export async function getCachedOpenCodeSessionList(options?: {
timeoutMs?: number;
forceRefresh?: boolean;
}): Promise<OpenCodeSessionListEntry[]> {
const timeoutMs = options?.timeoutMs ?? OPENCODE_SESSION_LIST_DEFAULT_TIMEOUT_MS;
const forceRefresh = options?.forceRefresh ?? false;
const now = Date.now();
if (sessionListCache) {
if (sessionListCache.promise) {
// A fetch is already in flight — every caller waits on it, even if
// they wanted a refresh.
return sessionListCache.promise;
}
if (
!forceRefresh &&
now - sessionListCache.timestamp < OPENCODE_SESSION_LIST_CACHE_TTL_MS
) {
return sessionListCache.entries;
}
}
const promise: Promise<OpenCodeSessionListEntry[]> = execFileAsync(
"opencode",
["session", "list", "--format", "json"],
{ timeout: timeoutMs, env: getOpenCodeChildEnv() },
)
.then(({ stdout }) => {
const entries = parseSessionListStdout(stdout);
if (sessionListCache?.promise === promise) {
sessionListCache = { entries, timestamp: Date.now() };
}
return entries;
})
.catch(() => {
if (sessionListCache?.promise === promise) {
sessionListCache = null;
}
return [] as OpenCodeSessionListEntry[];
});
sessionListCache = { entries: [], timestamp: now, promise };
return promise;
}
/**
* Drop any cached snapshot. Call this immediately after any code path that
* mutates opencode session state (delete, create) so that the next reader
* does not observe a stale entry.
*/
export function invalidateOpenCodeSessionListCache(): void {
// If a fetch is currently in flight we leave it alone — its result is
// about to land and a fresh fetch on top of it would be wasted work.
// Subsequent callers will see a stale snapshot for at most one tick;
// this is acceptable because the in-flight result already reflects state
// captured after the mutation began.
if (sessionListCache?.promise) {
sessionListCache = { ...sessionListCache, timestamp: 0 };
return;
}
sessionListCache = null;
}
/** Test-only: clear the cache including any in-flight promise. */
export function resetOpenCodeSessionListCache(): void {
sessionListCache = null;
}

View File

@ -73,6 +73,13 @@ import {
generateSessionName,
} from "./paths.js";
import { asValidOpenCodeSessionId } from "./opencode-session-id.js";
import {
getCachedOpenCodeSessionList,
getOpenCodeChildEnv,
invalidateOpenCodeSessionListCache,
resetOpenCodeSessionListCache as resetSharedOpenCodeSessionListCache,
type OpenCodeSessionListEntry,
} from "./opencode-shared.js";
import {
writeWorkspaceOpenCodeAgentsMd,
} from "./opencode-agents-md.js";
@ -114,10 +121,15 @@ async function deleteOpenCodeSession(sessionId: string): Promise<void> {
try {
await execFileAsync("opencode", ["session", "delete", validatedSessionId], {
timeout: 30_000,
env: getOpenCodeChildEnv(),
});
// Drop cached list immediately so reuse / remap / restore call sites
// do not observe the deleted id for the remainder of the TTL window.
invalidateOpenCodeSessionListCache();
return;
} catch (err) {
if (errorIncludesSessionNotFound(err)) {
invalidateOpenCodeSessionListCache();
return;
}
lastError = err;
@ -126,42 +138,15 @@ async function deleteOpenCodeSession(sessionId: string): Promise<void> {
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
interface OpenCodeSessionListEntry {
id: string;
title: string;
updatedAt?: number;
/** Re-export so existing core test-utils + session-manager call sites keep working. */
export function resetOpenCodeSessionListCache(): void {
resetSharedOpenCodeSessionListCache();
}
async function fetchOpenCodeSessionList(
timeoutMs = OPENCODE_DISCOVERY_TIMEOUT_MS,
timeoutMs: number = OPENCODE_DISCOVERY_TIMEOUT_MS,
): Promise<OpenCodeSessionListEntry[]> {
try {
const { stdout } = await execFileAsync("opencode", ["session", "list", "--format", "json"], {
timeout: timeoutMs,
});
const parsed = safeJsonParse<unknown>(stdout);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const title = typeof entry["title"] === "string" ? entry["title"] : "";
const id = asValidOpenCodeSessionId(entry["id"]);
if (!id) return [];
const rawUpdated = entry["updated"];
let updatedAt: number | undefined;
if (typeof rawUpdated === "number" && Number.isFinite(rawUpdated)) {
updatedAt = rawUpdated;
} else if (typeof rawUpdated === "string") {
const parsedUpdated = Date.parse(rawUpdated);
if (!Number.isNaN(parsedUpdated)) {
updatedAt = parsedUpdated;
}
}
return [{ id, title, ...(updatedAt !== undefined ? { updatedAt } : {}) }];
});
} catch {
return [];
}
return getCachedOpenCodeSessionList({ timeoutMs });
}
async function discoverOpenCodeSessionIdsByTitle(

View File

@ -34,7 +34,7 @@ vi.mock("node:child_process", () => ({
},
}));
import { create, manifest, default as defaultExport } from "./index.js";
import { create, manifest, default as defaultExport, resetOpenCodeSessionListCache } from "./index.js";
function makeSession(overrides: Partial<Session> = {}): Session {
return {
@ -95,6 +95,7 @@ function mockTmuxWithProcess(processName: string, found = true) {
beforeEach(() => {
vi.clearAllMocks();
resetOpenCodeSessionListCache();
});
describe("plugin manifest & exports", () => {

View File

@ -7,6 +7,10 @@ import {
getActivityFallbackState,
recordTerminalActivity,
asValidOpenCodeSessionId,
getCachedOpenCodeSessionList,
getOpenCodeChildEnv,
ensureOpenCodeTmpDir,
resetOpenCodeSessionListCache,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
@ -18,18 +22,13 @@ import {
type Session,
type WorkspaceHooksConfig,
type OpenCodeAgentConfig,
type OpenCodeSessionListEntry,
} from "@aoagents/ao-core";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
interface OpenCodeSessionListEntry {
id: string;
title?: string;
updated?: string | number;
}
function parseUpdatedTimestamp(updated: string | number | undefined): Date | null {
if (typeof updated === "number") {
if (!Number.isFinite(updated)) return null;
@ -54,20 +53,8 @@ function parseUpdatedTimestamp(updated: string | number | undefined): Date | nul
return new Date(parsedMs);
}
function parseSessionList(raw: string): OpenCodeSessionListEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is OpenCodeSessionListEntry => {
if (!item || typeof item !== "object") return false;
const record = item as Record<string, unknown>;
return asValidOpenCodeSessionId(record["id"]) !== undefined;
});
}
// Re-export for backward compat — see @aoagents/ao-core/opencode-shared.
export { resetOpenCodeSessionListCache };
/**
* Parse JSON stream lines from `opencode run --format json` output.
@ -158,13 +145,7 @@ async function findOpenCodeSession(
session: Session,
): Promise<OpenCodeSessionListEntry | null> {
try {
const { stdout } = await execFileAsync(
"opencode",
["session", "list", "--format", "json"],
{ timeout: 30_000 },
);
const sessions = parseSessionList(stdout);
const sessions = await getCachedOpenCodeSessionList();
// Prefer exact ID match from metadata
if (session.metadata?.opencodeSessionId) {
@ -273,6 +254,15 @@ function createOpenCodeAgent(): Agent {
env["AO_ISSUE_ID"] = config.issueId;
}
// Point Bun's embedded shared-library extraction at an AO-owned temp
// dir so the cli-side janitor only needs to sweep our own files
// (issue #1046). Setting all three keys covers POSIX (TMPDIR) and
// Windows fallbacks; opencode itself ships POSIX-only today.
const tmpDir = ensureOpenCodeTmpDir();
env["TMPDIR"] = tmpDir;
env["TMP"] = tmpDir;
env["TEMP"] = tmpDir;
// PATH and GH_PATH are injected by session-manager for all agents.
return env;
@ -452,7 +442,7 @@ export function create(): Agent {
export function detect(): boolean {
try {
execFileSync("opencode", ["version"], { stdio: "ignore" });
execFileSync("opencode", ["version"], { stdio: "ignore", env: getOpenCodeChildEnv() });
return true;
} catch {
return false;