Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
51f4da4
feat(workspace): offer to install the engine a bound workspace needs
Aug 27, 2026
7d7046b
fix: let the install offer return in a session that outlives the Not-…
Aug 27, 2026
4328acd
fix: re-probe a too-old engine as soon as the file on PATH changes
Aug 27, 2026
f3e1764
fix: re-raise the offer hourly past the latch window; scope the attac…
Aug 27, 2026
c6acbcb
fix: print the headless refusal line once per process, not once per s…
Aug 27, 2026
e0cd5c9
fix: wait for the TUI kv store to hydrate before consulting the Not-n…
Aug 27, 2026
cb056bf
fix: give the engine install a real deadline over npm's whole process…
Aug 27, 2026
4ab82ae
fix: keep the install deadline's SIGKILL armed after npm itself exits
Aug 27, 2026
62b9be6
chore: rebase onto the tenant-scoped overlay (declaredFor takes the w…
Aug 28, 2026
0176a55
fix: drop the filter box from the engine install offer dialog
Aug 28, 2026
cb69795
test(workspace): pin the "clears the floor" versions to the floor con…
Aug 30, 2026
c79dfe3
fix(workspace): hold the engine install offer until the kv store hydr…
Aug 31, 2026
a29d36c
fix(workspace): key the headless refusal line on the verdict, not the…
Aug 31, 2026
168dba8
fix(workspace): leave the count out of the offer when it is unknown
Aug 31, 2026
46711a5
fix(workspace): announce a refusal once per verdict, not once per count
Aug 31, 2026
afae5b8
fix(workspace): fingerprint the engine binary by identity, not size a…
Aug 31, 2026
aadaf28
fix(workspace): tell same-named workspaces apart in the refusal signa…
Aug 31, 2026
d448ff3
test(workspace): derive the second directory's overlay before its turn
Aug 31, 2026
e1cfbcf
fix(workspace): reap what outlives a successful npm install
Sep 1, 2026
68dcce4
fix(workspace): review follow-ups on the install offer's raise path
Sep 1, 2026
4f16f17
fix(workspace): keep the install's SIGKILL timers referenced; name sh…
Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 330 additions & 0 deletions packages/opencode/src/altimate/workspace/engine-offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
// altimate_change - new file
//
// The install offer for a workspace whose engine is missing or too old.
//
// State-free on purpose: the TUI plugin runs in its own module realm and
// receives the offer as a bare command over the event bus, so it re-derives
// the detail here from disk and PATH rather than from the overlay's memory.
// Offer, never install on the flow's own account — `installEngine` only ever
// runs from an explicit "Install now".
import { execFile, type ChildProcess } from "node:child_process"
import launch from "cross-spawn"
import { Process } from "@/util/process"
import { AppRuntime } from "@/effect/app-runtime"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import { readLocalBinding } from "./state"
import { isHeadless, log, syncInternals } from "./engine-seams"
import { declaredBounded, notify, printLine, versionOf, which } from "./engine-probes"
import { ENGINE_BINARY, ENGINE_PACKAGE, MIN_ENGINE_VERSION, clearsFloor, type Toast } from "./engine-types"

/** Node major the npm install path needs. The CLI itself is a self-contained
* binary and does not need Node — only this install route does. */
export const MIN_NODE_MAJOR = 20
/** How long "Install now" waits for npm before giving up. */
export const INSTALL_TIMEOUT_MS = 300_000
/** Command the TUI plugin registers to raise the install offer. The offer
* crosses to the TUI over the same event bus toasts use; it cannot cross
* in-process, because the plugin runtime loads plugins in a separate realm.
* `CommandExecute` carries no payload, so the plugin re-derives the offer
* with `describeOffer()`. */
export const OFFER_COMMAND = "altimate.workspace.engineInstallOffer"
/** How long "Not now" silences the offer for a workspace. The TUI latch and
* the per-session announce dedupe both key on this, so a session that
* outlives the latch sees the offer again instead of waiting for a new one. */
export const OFFER_SKIP_TTL_MS = 7 * 24 * 60 * 60 * 1000
/** Once a session's offer is older than the latch, how often it is raised
* again while the verdict stands. The TUI's latch starts when "Not now" is
* chosen, not when the offer was raised, so the attach side cannot know when
* it ends: it re-raises at this cadence and the TUI suppresses until then. */
export const OFFER_RECHECK_MS = 60 * 60 * 1000

/** A "no usable engine" state, described well enough for an interactive
* surface to act on it without re-deriving anything. */
export type EngineOffer = {
reason: "engine-missing" | "engine-too-old"
/** Stable id — the 7-day "Not now" latch keys on this, not the name. */
workspaceId: string
workspaceName: string
/** Declared, CLI-servable integration tools that are unavailable without
* it. Absent when the allowlist lookup failed or the API is not configured,
* so the text can drop the number rather than print 0. */
declared?: number
/** Version found — only set for "engine-too-old". */
found?: string
/** The exact install/update command. */
command: string
}

/** Interactive surface for the offer, in the same realm. Returns true when it
* took ownership. Deliberately synchronous: it claims the offer and renders
* out-of-band rather than making the turn boundary wait for a person. */
export type OfferHandler = (offer: EngineOffer) => boolean

export type InstallResult = { ok: true } | { ok: false; error: string }

/** npm spec to install. ALTIMATE_ENGINE_INSTALL_SPEC overrides it so E2E can
* point the real install path at a local tarball instead of the registry. */
export function installSpec(): string {
return process.env["ALTIMATE_ENGINE_INSTALL_SPEC"] || `${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`
}

/** The command shown, copied, printed, and run — always the same string, so
* "Copy command" hands over exactly what "Install now" would have executed. */
export function installCommand(): string {
return `npm i -g ${installSpec()}`
}

/** Re-derive the current "no usable engine" state for a directory, from the
* binding on disk and the engine on PATH. Null when there is nothing to offer:
* unbound, or an engine that clears the floor. */
export async function describeOffer(directory: string): Promise<EngineOffer | null> {
const binding = syncInternals.resolveBinding
? await syncInternals.resolveBinding(directory)
: await readLocalBinding(directory).catch((err) => {
// Transiently unreadable is not unbound; the overlay tells them apart
// and this dialog says nothing either way — leave a trace.
log.info("engine offer: binding unreadable, nothing offered this turn", { directory, err: String(err) })
return null
})
if (!binding) return null
const workspaceId = String(binding.datamateId)
const bin = which(ENGINE_BINARY)
const found = bin ? await versionOf(bin) : null
if (bin && clearsFloor(found)) return null
const declared = (await declaredBounded(workspaceId))?.keys.length
return {
reason: bin ? "engine-too-old" : "engine-missing",
workspaceId,
workspaceName: binding.datamateName,
...(declared === undefined ? {} : { declared }),
...(bin ? { found: found ?? "unknown" } : {}),
command: installCommand(),
}
}

/** Node major on PATH, or null when Node is absent. Gates "Install now": with
* no Node there is nothing to run npm with, so the offer shows the command. */
export function nodeMajor(): Promise<number | null> {
if (syncInternals.nodeMajor) return syncInternals.nodeMajor()
const bin = which("node")
if (!bin) return Promise.resolve(null)
return new Promise((resolve) => {
execFile(bin, ["--version"], { timeout: 5000 }, (err, stdout) => {
if (err) return resolve(null)
const major = Number.parseInt(stdout.trim().replace(/^v/, "").split(".")[0] ?? "", 10)
resolve(Number.isFinite(major) ? major : null)
})
})
}

/** Whether npm can be invoked at all. Node and npm are separate packages on
* several Linux distributions, so Node 20+ does not imply `npm i -g` runs. */
export function npmAvailable(): boolean {
if (syncInternals.npmAvailable) return syncInternals.npmAvailable()
return which(process.platform === "win32" ? "npm.cmd" : "npm") !== null
}

/** Options are the process-group and deadline handling for the one command
* the offer runs. Nothing here spawns a shell. */
export type InstallRun = { code: number | null; timedOut: boolean; stderr: string }
/** After the deadline's SIGTERM, how long the tree gets before SIGKILL and the
* run is reported as timed out regardless of what is still alive. */
export const INSTALL_KILL_GRACE_MS = 5_000

/** Run the install command with a real deadline. npm forks a tree (scripts,
* node), and a descendant that outlives npm can keep the stderr pipe open, so
* the run settles on the child's `exit`, never on `close`, and the deadline
* signals the whole process group (POSIX: the child is its own group leader;
* Windows: taskkill /T) — SIGTERM first, SIGKILL after the grace, then the run
* reports the timeout whether or not anything is still holding a pipe. */
export function runInstall(
argv: string[],
timeoutMs = INSTALL_TIMEOUT_MS,
graceMs = INSTALL_KILL_GRACE_MS,
): Promise<InstallRun> {
if (syncInternals.runInstall) return syncInternals.runInstall(argv, timeoutMs, graceMs)
return new Promise((resolve) => {
const grouped = process.platform !== "win32"
let child: ChildProcess
try {
child = launch(argv[0], argv.slice(1), {
stdio: ["ignore", "ignore", "pipe"],
detached: grouped,
windowsHide: process.platform === "win32",
})
} catch (err) {
resolve({ code: null, timedOut: false, stderr: err instanceof Error ? err.message : String(err) })
return
}
let stderr = ""
child.stderr?.on("data", (chunk) => {
stderr = (stderr + String(chunk)).slice(-4096)
})
let timedOut = false
let settled = false
let hard: ReturnType<typeof setTimeout> | undefined
const finish = (code: number | null) => {
if (settled) return
settled = true
clearTimeout(timer)
// Past the deadline the escalation stays armed: npm (the group leader)
// usually dies on SIGTERM, but a descendant that ignores it must still
// get the SIGKILL, so the leader's exit does not cancel it.
// A descendant that inherited stderr can hold the pipe open after npm
// exits; nothing more is read from it once the run has settled.
child.stderr?.destroy()
resolve({ code, timedOut, stderr })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
const killTree = (signal: NodeJS.Signals) => {
if (grouped && child.pid) {
try {
process.kill(-child.pid, signal)
return
} catch {
// The group is already gone; fall through to the child itself.
}
}
if (process.platform === "win32") {
void Process.stop(child)
return
}
try {
child.kill(signal)
} catch {
// Already exited.
}
}
const timer = setTimeout(() => {
timedOut = true
killTree("SIGTERM")
hard = setTimeout(() => {
// The group outlives its leader while any member is alive, so this
// reaches survivors even after npm itself has exited. Referenced on
// purpose: an unref'd timer never fires in a process that is draining
// its loop, and the SIGKILL is the only thing that ends a descendant
// that ignored SIGTERM — five seconds of loop is the price.
killTree("SIGKILL")
finish(null)
}, graceMs)
}, timeoutMs)
child.once("exit", (code) => {
finish(code)
// npm is done, but a descendant it forked may still be alive in the
// group (a lifecycle script's daemon). It is the install's straggler,
// not the user's: reap it — SIGTERM now, SIGKILL after the grace —
// without holding the result for it. The timer stays referenced for
// the same reason the deadline's does: it must actually fire.
if (!timedOut && grouped) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, successful installs still leave lifecycle descendants running because this guard disables the cleanup path, and Process.stop cannot stop a tree after the direct child has exited. Add a Windows-capable tree/job cleanup that is captured before the leader exits, or otherwise ensure successful npm.cmd installs reap their descendants too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/engine-offer.ts, line 210:

<comment>On Windows, successful installs still leave lifecycle descendants running because this guard disables the cleanup path, and `Process.stop` cannot stop a tree after the direct child has exited. Add a Windows-capable tree/job cleanup that is captured before the leader exits, or otherwise ensure successful `npm.cmd` installs reap their descendants too.</comment>

<file context>
@@ -201,7 +201,17 @@ export function runInstall(
+      // group (a lifecycle script's daemon). It is the install's straggler,
+      // not the user's: reap it — SIGTERM now, SIGKILL after the grace —
+      // without holding the result or this process for it.
+      if (!timedOut && grouped) {
+        killTree("SIGTERM")
+        setTimeout(() => killTree("SIGKILL"), graceMs).unref()
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined for this PR, recorded under residual R2 ("Windows npm.cmd follows existing precedent in this repo rather than a test"): Windows has no process group to signal, and taskkill /T (the repo's existing Process.stop precedent, which the deadline path here also relies on) only reaches a tree while its leader is alive — so the deadline path on Windows already carries the same limit, and a post-exit reap would need a Job Object or a pre-exit descendant capture that nothing else in this codebase has. The published engine has no lifecycle daemon to leave behind (its only install-time script is a guarded no-op outside a git checkout), so this is the adversarial-package case on a platform this PR does not verify. Noted in the review log as the Windows half of R2 so it is not lost.

killTree("SIGTERM")
setTimeout(() => killTree("SIGKILL"), graceMs)
}
})
child.once("error", (err) => {
stderr = stderr || err.message
finish(null)
})
})
}

/** `npm i -g <spec>` with a deadline (`runInstall`). A zero exit is not a
* usable engine — npm's global bin directory need not be on PATH — so the same
* discovery the turn boundary does runs before success. */
export async function installEngine(): Promise<InstallResult> {
const spec = installSpec()
if (syncInternals.install) return syncInternals.install(spec)
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
const run = await runInstall([npm, "i", "-g", spec])
if (run.timedOut) {
return { ok: false, error: `npm did not finish within ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes` }
}
if (run.code === 0) {
const installedBin = which(ENGINE_BINARY)
if (!installedBin) {
return {
ok: false,
error: `npm installed it, but ${ENGINE_BINARY} is not on PATH — add your npm global bin directory to PATH`,
}
}
const installedVersion = await versionOf(installedBin)
if (!installedVersion) {
// Could not run or did not answer: say what was observed, no diagnosis.
return {
ok: false,
error:
`npm installed it, but the ${ENGINE_BINARY} first on PATH (${installedBin}) did not report a version — ` +
`try \`${ENGINE_BINARY} --version\` there before retrying`,
}
}
if (!clearsFloor(installedVersion)) {
// A real, older version first on PATH: an earlier install shadows the
// one npm just wrote.
return {
ok: false,
error:
`npm installed it, but the ${ENGINE_BINARY} first on PATH (${installedBin}) reports ${installedVersion} — ` +
`an older install earlier on PATH is shadowing the new one; remove it, or put npm's global bin directory ahead of it`,
}
}
return { ok: true }
}
const detail = run.stderr.trim().split(/\r?\n/).slice(-3).join(" ")
return { ok: false, error: detail || `npm exited with code ${run.code ?? "unknown"}` }
}

/** Ask the TUI to raise the offer. False when the bus is unavailable. The
* session is carried so an attached headless run, which reads the same event
* stream, prints the offer raised for its own session only. */
async function publishOffer(sessionID: string): Promise<boolean> {
if (syncInternals.publishOffer) return syncInternals.publishOffer(sessionID)
try {
await AppRuntime.runPromise(
EventV2Bridge.Service.use((events) =>
events.publish(TuiEvent.CommandExecute, { command: OFFER_COMMAND, sessionID }),
),
)
return true
} catch (err) {
log.warn("could not publish the engine install offer", { err: String(err) })
return false
}
}

/** Hand the offer to a same-realm surface. False when none is registered. */
function offerInstall(offer: EngineOffer): boolean {
const handler = syncInternals.offer
if (!handler) return false
try {
return handler(offer)
} catch (err) {
log.warn("install offer surface failed; falling back to toast", { err: String(err) })
return false
}
}

/** One printed line for headless `run`. */
export function describeOfferLine(offer: EngineOffer): string {
const tools = toolsNeed(offer.declared)
return offer.reason === "engine-too-old"
? `Workspace "${offer.workspaceName}": ${tools} ${ENGINE_BINARY} ${MIN_ENGINE_VERSION}+ (found ${offer.found ?? "unknown"}). Update with: ${offer.command}`
: `Workspace "${offer.workspaceName}": ${tools} the local engine, which is not installed. Install it with: ${offer.command}`
}

/** "N integration tools need" / "1 integration tool needs" / "its integration
* tools need" when the count is unknown. */
export function toolsNeed(declared: number | undefined): string {
if (declared === undefined) return "its integration tools need"
return declared === 1 ? "1 integration tool needs" : `${declared} integration tools need`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Offer via the dialog surface when there is one; otherwise print (headless)
* or toast (bus unavailable). Exactly one of these happens. */
export async function offerOrNotify(offer: EngineOffer, toast: Toast, sessionID: string): Promise<void> {
if (isHeadless()) {
printLine(describeOfferLine(offer))
Comment thread
ralphstodomingo marked this conversation as resolved.
return
}
if (offerInstall(offer)) return
if (await publishOffer(sessionID)) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the bus accepts CommandExecute but no surface handles OFFER_COMMAND, publishOffer returns true and offerOrNotify skips the toast, so the engine-missing/-too-old notice is lost. publish only confirms the bus accepted the event, not that a handler consumed it. Confirm surface ownership (e.g. a registered-handler flag) before treating a publish as delivered, or fall back to the toast when the bus is available but the offer command is unhandled.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/engine-offer.ts, line 285:

<comment>When the bus accepts `CommandExecute` but no surface handles `OFFER_COMMAND`, `publishOffer` returns true and `offerOrNotify` skips the toast, so the engine-missing/-too-old notice is lost. `publish` only confirms the bus accepted the event, not that a handler consumed it. Confirm surface ownership (e.g. a registered-handler flag) before treating a publish as delivered, or fall back to the toast when the bus is available but the offer command is unhandled.</comment>

<file context>
@@ -0,0 +1,287 @@
+    return
+  }
+  if (offerInstall(offer)) return
+  if (await publishOffer(sessionID)) return
+  await notify(toast)
+}
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined with evidence: the fallback toast travels over the same bus (notify() publishes TuiEvent.ToastShow, engine-probes.ts:164-168), so a bus with no TUI handling CommandExecute would drop the toast identically — there is no surface the fallback would reach that the offer does not. Claim 2 defines the toast as the fallback for an unavailable bus, which is what publishOffer returning false means; headless runs print instead.

await notify(toast)
}
Loading
Loading