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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ against the current tree at this cycle's P before implementing.

---

## wp4 P stale-check + amendments (binding over the lane text below)

Stale-check at 37622b92d (stack pos 2 head): `cliEntry(runtime)` :66, `writeServiceInstallState(backend, launcherPath)` :238, `buildPlist` :489-527, `installLaunchd` :2244-2272, `buildServiceLauncherShellCommand` :567, `buildUnit` launcher/env shape :3268-3301, `bakedServicePathsDiagnostic` launcher-aware :3236 — all as the lane text assumed.

Amendment (missed by the lane): `launchdStart` (:2281-2300) decides "already loaded from the current plist" by matching the live `launchctl print` output against `buildServiceShellCommand(entry.bun, entry.cli)`. After this change a launcher-baked plist would never match that string, so every healthy `ocx service start` on macOS would fall through to the failure branch. Fix: derive the expected command from the recorded install state — `readServiceInstallState()?.launcherPath` present ⇒ `buildServiceLauncherShellCommand(launcherPath)`, else the Bun+CLI pair — and pin it with a test in the existing `launchdStart` describe.

`ServiceInstallState.launcherPath` doc comment says "Linux only"; update to "launchd and systemd".

Docs: lifecycle.md :245/:256 as the lane text says; structure/04 :36 generalised. Optional `version-skew.ts` wording change is **deferred** (separate concern, keeps this PR to launcher parity).

---

1) VERDICT: FIXABLE

Launchd parity is **not fixed**. The stable launcher fixes which version starts next; it does **not** replace an already-running proxy after `mise upgrade`.
Expand Down Expand Up @@ -163,3 +175,10 @@ Risk: explicit security review is warranted under [MAINTAINERS.md:60](/Users/jun
- Stable-launcher parity does **not** satisfy automatic repair/request refusal during an already-running version mismatch. That remains a separate product decision.
- Actual mise shim selection under launchd’s login-shell environment needs a macOS smoke check; no live service or real upgrade was exercised.
- Read-only investigation only: no files, Git state, GitHub comments, service state or tests were changed/run.

Audit round 1 fold (GO-WITH-FIXES, 1 blocker): `serviceStatusReport()` (:4243) compares the live
job against `buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort())` too —
same fix as `launchdStart`: one helper `expectedLaunchdCommand(port)` that reads
`readServiceInstallState()?.launcherPath` and returns the launcher command when present, else the
Bun+CLI pair (never rediscovering PATH at start/status). `launchdStart` also gets the installed port.

1 change: 1 addition & 0 deletions devlog/_plan/260905_bug_triage_stack/060_ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ Append-only. One row per external action or verification.

| 2026-09-04T22:53Z | #3467 / PR #3547 | pushed fd1dbbedb (--no-verify), PR opened base dev | typecheck 0; focused 48 pass; privacy ok; CI trailing |
| 2026-09-04T23:16Z | #3462 / PR #3551 | pushed 9611cdf4b (--no-verify), PR opened base #3547 head | typecheck 0; focused 71 pass; privacy ok; CI trailing |
| 2026-09-04T23:36Z | #3464 / PR #3554 | pushed b4fbd293f (--no-verify), PR opened base #3551 head, Refs not Closes | typecheck 0; service.test.ts 199 pass; privacy ok; CI trailing |
18 changes: 11 additions & 7 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,18 +242,22 @@ interrupted package update removed either file, it logs one `installation is inc
stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then
run `ocx service repair` to refresh the task with the restored package paths.

On Linux, the systemd unit invokes the first regular, executable `ocx` file found on `PATH` at
install time rather than the Bun and CLI paths inside the installed package tree. Version managers such as
On macOS and Linux, the launchd plist and the systemd unit invoke the first regular, executable
`ocx` file found on `PATH` at install time rather than the Bun and CLI paths inside the installed
package tree. Version managers such as
**mise** and **asdf** install into a versioned directory and delete the old one on upgrade, which
used to leave the unit pointing at files that no longer existed — systemd then restart-looped while
still reporting the service as installed. A shim path survives the upgrade, so the unit keeps
resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A
used to leave the service definition pointing at files that no longer existed — systemd then
restart-looped while still reporting the service as installed, and launchd kept the old build serving
until it was restarted by hand. A shim path survives the upgrade, so the definition keeps resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A
trusted `OPENCODEX_BUN_PATH` selected before Bun starts is preserved through the shim; package-local
bundled Bun paths are deliberately rediscovered after upgrades instead of being pinned in the unit.

Units installed before this change still carry the old versioned paths and cannot migrate
Definitions installed before this change still carry the old versioned paths and cannot migrate
themselves — once the old executable is deleted, no opencodex code runs to fix it. Run
`ocx service repair` once after upgrading; subsequent version changes need no action.
`ocx service repair` once after upgrading; after that, each service start follows the launcher.
An already-running proxy is not replaced by an external upgrade: restart the service (or run
`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as
exactly that signal.

| Subcommand | Action |
| --- | --- |
Expand Down
71 changes: 55 additions & 16 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@ export interface ServiceInstallState {
bunPath?: string;
cliPath?: string;
/**
* Linux only. The stable `ocx` launcher the unit actually invokes, when one was found.
* Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd
* runs — so staleness must be judged against THIS path instead. A version-manager
* launchd and systemd. The stable `ocx` launcher the service definition actually invokes,
* when one was found. Present means `bunPath`/`cliPath` are provenance for the install,
* NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager
* upgrade replaces the directory those two point into while the launcher survives, and
* checking the old pair would report a stale service that is in fact healthy.
*/
Expand Down Expand Up @@ -486,25 +486,48 @@ function writeServiceApiTokenFile(): string | null {
return path;
}

export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
const { bun, bunRuntimeSource, cli } = cliEntry();
/**
* Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx`
* executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a
* version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up
* on the next launchd start instead of leaving the old build serving (#3464 — the macOS
* counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the
* legacy pair so callers and tests stay hermetic.
*/
export function buildPlist(
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {},
): string {
const runtime = deps.runtime ?? durableBunRuntime();
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
const launcher = deps.launcher ?? null;
const log = logPath();
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
const codexHome = process.env.CODEX_HOME?.trim();
const codexSqliteHome = currentCodexSqliteHomeAbsolute();
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
const envLines = [
` <key>OCX_SERVICE</key><string>1</string>`,
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
...(launcher ? [] : [
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
]),
// A launcher resolves the current package's bundled Bun after every upgrade. Preserve
// only a proof-bound shell override; baking a package-local path here would recreate
// the version-manager pin that launcher mode exists to remove (same rule as buildUnit).
launcher && runtime.source === "override"
? ` <key>${runtime.overrideEnv}</key><string>${plistString(runtime.path)}</string>`
: null,
` <key>PATH</key><string>${plistString(path)}</string>`,
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
...proxyEnv.map(({ name, value }) =>
` <key>${name}</key><string>${plistString(value)}</string>`),
].filter((line): line is string => Boolean(line)).join("\n");
const command = buildServiceShellCommand(bun, cli);
const command = launcher
? buildServiceLauncherShellCommand(launcher)
: buildServiceShellCommand(bun, cli);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Expand Down Expand Up @@ -569,6 +592,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`;
}

/**
* The exec line the installed launchd plist is expected to carry, derived from the recorded
* install state rather than rediscovered: a launcher install runs the launcher, a legacy or
* stateless install runs the Bun + CLI pair. `start` and `status` compare the live job
* against this, so both must follow the launcher or a healthy launcher-backed job reads as
* "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here.
*/
export function expectedLaunchdCommand(
port: number,
deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {},
): string {
const state = deps.state === undefined ? readServiceInstallState() : deps.state;
if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port);
const entry = deps.entry ?? cliEntry();
return buildServiceShellCommand(entry.bun, entry.cli, port);
}

/**
* The `--port <n>` actually baked into the installed launchd plist, or null when it
* cannot be read. macOS only — named for launchd rather than "service" so no caller
Expand Down Expand Up @@ -2251,7 +2291,10 @@ function installLaunchd(): void {
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
// so a post-write existsSync would call every fresh install an "installed" service.
const wasInstalled = existsSync(p);
writeServiceDefinitionFile(p, buildPlist(), "utf8");
// Resolve the launcher ONCE and hand the same value to the plist and to install state,
// so the staleness diagnostic judges exactly what launchd runs.
const launcher = stableLauncherEntry();
writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8");
// Best-effort: an absent job is fine here, and a failed unload is caught by the
// load verification below with a better message than a raw unload error.
runLaunchctl(["unload", p]);
Expand All @@ -2268,7 +2311,7 @@ function installLaunchd(): void {
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
);
}
writeServiceInstallState();
writeServiceInstallState("scheduler", launcher);
}
/**
* Deps are named for the layer they replace, not for the process API: `launchctl`
Expand All @@ -2291,9 +2334,8 @@ export function startLaunchd(deps: {
// already be bootstrapped from THIS plist, which is a no-op rather than an error.
// `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
// throwing here would break `ocx service start` on every healthy service.
const entry = cliEntry();
const live = (deps.matches ?? launchdJobMatchesPlist)(
buildServiceShellCommand(entry.bun, entry.cli),
expectedLaunchdCommand(installedServiceListenPort()),
);
if (live.loaded && live.matchesPlist) {
console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
Expand Down Expand Up @@ -4234,14 +4276,11 @@ export async function serviceStatusReport(
// Linux/Windows and make the stale-plist case untestable there.
const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
? (() => {
const entry = cliEntry();
// Pass the INSTALLED port explicitly: the default third argument is
// resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
// a config edit the expected string would never match and every run would
// print a false "OLDER plist".
return launchdJobMatchesPlist(
buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()),
);
return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort()));
})()
: null);
const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
Expand Down
12 changes: 7 additions & 5 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,21 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b
- 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence.
- 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess.

## Linux stable service launcher
## Stable service launcher (launchd and systemd)

Systemd installation resolves the first absolute `ocx` PATH candidate that is both a regular file
Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file
and executable, keeps that path lexical so a version-manager shim remains an indirection, and
records the same single resolution in the unit and service state. Unit construction never performs
PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI
records the same single resolution in the service definition and service state. Definition
construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI
fallback, keeping diagnostics and tests independent of the host PATH.

Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that
versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound
`OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are
rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by
the service shell at start.
the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job
against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking
PATH, so a launcher-backed job is never misreported as an older plist (#3464).

[Decision Log]
- 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder.
Expand Down
Loading
Loading