Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f29a02a
fix(update): tell a history-only stop failure from a real one
lidge-jun Aug 31, 2026
03fad52
fix(update): probe the endpoint, and never call a failed service stop…
lidge-jun Aug 31, 2026
84a4a2a
fix(update): stop guessing when the liveness probe cannot answer
lidge-jun Aug 31, 2026
5b9fd79
fix(update): one post-stop decision, and an identity rule that matches
lidge-jun Aug 31, 2026
0760918
fix(update): verify the respawn window, and normalize the host once
lidge-jun Aug 31, 2026
be41c3e
fix(service): charge the respawn wait only to the backend that respawns
lidge-jun Aug 31, 2026
56235a6
fix(stop): defer shared teardown until the proxy is proven down
lidge-jun Aug 31, 2026
f921120
fix(stop): make the deferred teardown a durable obligation
lidge-jun Aug 31, 2026
89386db
fix(stop): bind the deferral to a receipt identity, not to presence
lidge-jun Aug 31, 2026
9a60ccc
fix(stop): gate the restore on the inherited obligation, not just lab…
lidge-jun Aug 31, 2026
34ba69c
fix(stop): put the nonce in the filename so a clear cannot race
lidge-jun Aug 31, 2026
3d8574b
fix(stop): share the receipt naming rule, and stop leaking obligations
lidge-jun Aug 31, 2026
b4d434f
fix(stop): keep an unreadable obligation enforcing, and always leave …
lidge-jun Aug 31, 2026
754ec33
fix(stop): record endpoint provenance, and re-check obligations after…
lidge-jun Aug 31, 2026
311cf98
fix(stop): say what quarantine actually does
lidge-jun Aug 31, 2026
b643b93
fix(restore): finish the Grok half when the Codex half is a no-op
lidge-jun Aug 31, 2026
a249c69
fix(restore): strip the fence on every path, and stop reading a faile…
lidge-jun Aug 31, 2026
920cd1c
fix(stop): make every teardown path report the whole outcome
lidge-jun Aug 31, 2026
fdb75ab
fix(stop): refuse a respawnable backend before touching it, not after
lidge-jun Aug 31, 2026
01cfde8
fix(stop): only a proven absence is safe, and say so in every locale
lidge-jun Aug 31, 2026
ff641b9
fix(stop): give an unreadable scheduler state its own answer, not a loop
lidge-jun Aug 31, 2026
cc53ce6
fix(stop): carry the unreadable-scheduler diagnosis into ocx stop too
lidge-jun Aug 31, 2026
237643c
fix(uninstall): stop reading an unreadable service state as "not inst…
lidge-jun Aug 31, 2026
a043e31
fix(uninstall): find the orphan, and make the authorization rule call…
lidge-jun Aug 31, 2026
be348f8
fix(uninstall): stop manufacturing proof out of three different unknowns
lidge-jun Aug 31, 2026
37f0d1f
fix(uninstall): prove every endpoint, and stop reading silence as a d…
lidge-jun Aug 31, 2026
0ae6190
docs(devlog): record what 26 review rounds added to the #3008 unit
lidge-jun Aug 31, 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
64 changes: 58 additions & 6 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
* src/cli/index.ts — only the published npm `bin` routes through here.)
*/
import { spawn, spawnSync } from "node:child_process";
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs";
import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs";
import { decidePostStopUpdate } from "../src/update/stop-decision.mjs";
import { randomBytes } from "node:crypto";
import { createRequire } from "node:module";
import { existsSync, readFileSync, readdirSync } from "node:fs";
Expand All @@ -17,6 +20,7 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs";
import { npmInvocation } from "../src/update/npm-invocation.mjs";
import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs";
import {
npmCachePreflightFailureMessage,
runNpmCachePreflight,
Expand Down Expand Up @@ -202,7 +206,12 @@ function runNpmSelfUpdate() {
// Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker).
// Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded.
let bakePort = 10100;
// The hostname travels with the port: a proxy bound to ::1 or a specific interface is
// invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as
// "stopped" for exactly the proxy the probe exists to find.
let bakeHostname = "127.0.0.1";
let sawRuntimePort = false;
let sawRuntimeHostname = false;
try {
const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8"));
if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) {
Expand All @@ -219,16 +228,29 @@ function runNpmSelfUpdate() {
}
if (runtimeLive) {
bakePort = Math.trunc(rt.port);
if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") {
bakeHostname = rt.hostname.trim();
sawRuntimeHostname = true;
}
sawRuntimePort = true;
}
}
} catch { /* fall through to config */ }
if (!sawRuntimePort) {
// Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no
// hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1.
if (!sawRuntimePort || bakeHostname === "127.0.0.1") {
try {
const cfg = JSON.parse(readFileSync(join(configDir(), "config.json"), "utf8"));
if (Number.isFinite(cfg?.port) && cfg.port > 0 && cfg.port <= 65535) bakePort = Math.trunc(cfg.port);
if (!sawRuntimePort && Number.isFinite(cfg?.port) && cfg.port > 0 && cfg.port <= 65535) {
bakePort = Math.trunc(cfg.port);
}
if (!sawRuntimeHostname && typeof cfg?.hostname === "string" && cfg.hostname.trim() !== "") {
bakeHostname = cfg.hostname.trim();
}
} catch { /* keep default */ }
}
// Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes
// get it from one place.

const launcher = fileURLToPath(import.meta.url);

Expand Down Expand Up @@ -343,17 +365,47 @@ function runNpmSelfUpdate() {
}
}

if (serviceWasInstalled || hasRuntimeState) {
// An outstanding pending-teardown receipt is a fourth reason to run the stop. After a
// parent crashed mid-deferral the service, pid and runtime records can all be absent
// while the shared client config still points at a proxy that is gone; installing over
// that silently skips the recovery the receipt was written to trigger (#3008). Presence
// is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides
// whether the obligation is safe to finish.
const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir());
if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown) {
console.log("⏹ Stopping the running proxy before updating...");
const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true });
const stillHasRuntimeState =
existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
if (stopRes.status !== 0 || stillHasRuntimeState) {
// A history-only failure means teardown succeeded and a backup manifest is waiting for
// review: the proxy is down and replacing package files is safe. Every other nonzero
// status is a stop that did not finish, and a signal kill (status null) says nothing
// about whether it did - both abort, because replacing files under a live server
// leaves it running mixed old and new modules (#3008).
// The same decision the Bun updater makes, from the same module (#3008). Absent PID and
// runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts
// because a silent listener is exactly the state where replacing files is dangerous.
const decision = decidePostStopUpdate({
status: stopRes.status,
hasRuntimeState: stillHasRuntimeState,
// Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed
// (there is nothing left to stop), so a pre-stop check alone let the retry install
// over a teardown that never ran.
teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()),
liveness: probeProxyLiveness(bakePort, bakeHostname),
});
const historyOnlyStop = decision.reason === "history-only";
if (!decision.proceed) {
if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
console.error("opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
if (decision.reason === "teardown-outstanding") {
console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update.");
console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home.");
} else console.error(decision.reason === "proxy-unknown"
? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.`
: "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
process.exit(1);
}
if (historyRestoreIncomplete()) {
if (historyOnlyStop || historyRestoreIncomplete()) {
console.warn(
"opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
" The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
Expand Down
68 changes: 68 additions & 0 deletions devlog/_plan/260831_prio70_train_round2/051_wp5_outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 050 outcome — wp5 (#3008): what the implementation added beyond this plan

The plan described one defect: `ocx update` aborting because `ocx stop` could not tell a
history-only failure from a real stop failure. That fix is in the first commit of the
branch. The other twenty-five came from adversarial review, and they are not incidental —
each one is the same defect wearing different clothes: **a teardown that reports success
while half of it did not happen.**

## The shape that kept recurring

Something cannot be determined, and the code treats "could not determine" as "determined
to be fine". Every instance authorized taking shared client config down while a proxy
might still be serving, which leaves Codex or Grok pointed at a process that is gone.

| Where | What was read as proof | Round |
| --- | --- | --- |
| `POST /api/stop` | `stopServiceIfInstalled` false — collapsed "not installed" with "refused to stop" | 17 |
| `POST /api/stop` | Success decided from the native restore alone, Grok failure appended as text | 17 |
| `ocx service stop` / `uninstall` | Restore and strip failures logged, exit code still 0 | 17 |
| Route pre-check | Scheduler stopped first, refused second — mutate-then-refuse | 18 |
| Daemon exit | Drain success alone, ignoring the teardown result | 18 |
| Respawn predicate | `status === "present"`, so an unreadable probe passed as absent | 19 |
| `ocx restore` | Early return on the Codex no-op path, never reaching the Grok strip | 15 |
| `ocx restore --json` | Same, on the ordinary forward path | 16 |
| Receipt scan | Every `readdir` error read as "no obligations" | 16 |
| `ocx uninstall` | `stopServiceIfInstalled` false read as "not installed" | 22 |
| `ocx uninstall` | Missing pid file read as "no proxy serving" | 23 |
| `ocx uninstall` | `uninstallServiceIfInstalled` false — absence and removal failure | 24 |
| `ocx uninstall` | Registration removed, running wrapper assumed dead | 24 |
| `ocx uninstall` | `findLiveProxy` null read as proof of absence | 24 |
| `ocx uninstall` | One endpoint probed while two were candidates | 25 |
| `ocx uninstall` | `proxyStillLiveAfterStop` null read as a verified window | 25 |

## The deferral, and why it needed four attempts

`ocx stop` has to defer shared teardown to itself, because the proxy exits before anyone
can verify a Task Scheduler wrapper did not respawn it. Expressing that obligation took
four tries:

1. A query flag. Any authenticated caller could set it and exit, and a parent that died
mid-stop left nothing on disk saying a restore was owed.
2. A receipt file. Presence is not ownership — another caller could ride on it.
3. A nonce inside one shared file. Read-compare-unlink is three syscalls, so a concurrent
stop replacing the file between the compare and the unlink got its obligation deleted.
4. **The nonce as the filename.** `unlink` names one specific obligation and cannot reach
another. Two concurrent stops hold two receipts, which is the truth of the situation.

The receipt also carries the endpoint being stopped and how it was obtained. A configured
address is recorded as `guessed` and never authorizes automatic recovery: a proxy on an
explicit `--port` can be respawned there while the configured port refuses.

## Tests that passed for the wrong reason

Five times a regression was written as a source-text assertion, and five times reverting
the defect left it green. The reviewer caught each one. Where a rule mattered it was
extracted into something callable — `performStopTeardown`, `classifyWindowsServiceStop`,
`sharedTeardownAuthorized`, `endpointsToProve`, `everyEndpointProvenDown` — and the test
now executes the permutations. Source assertions remain only for wiring: that a route
delegates to the extracted rule rather than growing a second copy.

Every fix on this branch was driven RED against the specific defect and restored.

## Docs

Sixteen files across eight locales carry the new refusal contract: `respawnable_service`
and `service_state_unknown`, and the fact that the dashboard Stop button refuses on the
Windows Task Scheduler backend rather than half-performing a stop it cannot verify.

4 changes: 2 additions & 2 deletions docs-site/src/content/docs/fr/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ gestionnaire de mots de passe.
| **Journaux** | Actualisez automatiquement les requêtes récentes et consultez les jetons, l'effort demandé et, lorsqu'il est disponible, l'effort sortant effectif, le modèle résolu, le fournisseur, l'état, l'identifiant de requête, la durée et les détails de l'erreur. La vue détaillée inclut le champ exact de raisonnement transmis lorsque l'adaptateur en émet un. Filtrez par identifiant opaque de conversation ou de session — si le client en fournit un — afin d'obtenir le total des jetons et le coût estimé au tarif catalogue pour l'anneau de journaux actuellement chargé. |
| **Utilisation / Débogage** | Examinez la couverture et les tendances d'utilisation des jetons, ou activez à la demande les diagnostics de transport et d'extraction de l'utilisation propres aux fournisseurs. |
| **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. |
| **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). |
| **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. |

### Liens directs vers une section

Expand Down Expand Up @@ -174,7 +174,7 @@ L'interface graphique est un client léger de l'API JSON de gestion du proxy. Pa
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Ajouter un compte au groupe au moyen d’une connexion dans le navigateur. |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Lire les métadonnées des requêtes récentes avec des filtres facultatifs de fin de journal, de fournisseur et d'état exact ou par classe. Avec `limit`/`offset`, la pagination remonte depuis la ligne la plus récente (`offset=0` renvoie la dernière page). Forme de la réponse : `{ timeZone, total, logs }`, où `total` est le nombre de lignes filtrées avant pagination. |
| `GET` / `PUT /api/subagent-models` | Lire ou définir les cinq modèles de remplacement `spawn_agent` mis en avant. |
| `POST /api/stop` | Arrêter le proxy et le service, restaurer Codex natif et quitter. |
| `POST /api/stop` | Arrêter le proxy et le service, restaurer Codex natif et quitter. Refusé avec `respawnable_service` sur le backend Planificateur de tâches Windows, et avec `service_state_unknown` lorsque cet état ne peut pas être lu ; rien n'est modifié dans les deux cas. |

:::tip
L'ajout d'**Ollama Cloud** ou d'un autre fournisseur doté d'un catalogue depuis le tableau de bord copie sa
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/fr/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ ocx start --port 8080

### `ocx stop`

Arrête le proxy actif à partir de son PID, supprime le fichier de PID et rétablit le fonctionnement natif de Codex. Si un service d’arrière-plan géré est installé, `ocx stop` l’arrête d’abord afin qu’il ne puisse pas relancer le proxy. La même opération est disponible avec le bouton **Stop** du tableau de bord Web (`POST /api/stop`).
Arrête le proxy actif à partir de son PID, supprime le fichier de PID et rétablit le fonctionnement natif de Codex. Si un service d’arrière-plan géré est installé, `ocx stop` l’arrête d’abord afin qu’il ne puisse pas relancer le proxy. Le bouton **Stop** du tableau de bord Web exécute la même opération (`POST /api/stop`) sur tous les backends, sauf le Planificateur de tâches Windows : le wrapper peut y relancer le proxy après la fin de la tâche, donc le tableau de bord refuse avec `respawnable_service`, ne modifie rien et vous demande d'exécuter `ocx stop`.

### `ocx restart`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ lui-même s'il souhaite ajouter une étoile au dépôt.
| --- | --- | --- |
| `GET /api/system/memory` | Renvoyer les mesures scalaires du processus, du tas, des flux, de l'état des réponses, du mécanisme de surveillance et des tours actifs | — |
| `POST /api/system/restart` | Amorcer un redémarrage du processus qui attend l'évacuation des requêtes, sans retirer l'injection du client | Renvoie 202 ; les appels répétés signalent l'évacuation déjà en cours |
| `POST /api/stop` | Arrêter le service, restaurer Codex en mode natif, retirer l'injection Grok gérée et évacuer les requêtes du proxy | 409 conflit de propriété du service |
| `POST /api/stop` | Arrêter le service, restaurer Codex en mode natif, retirer l'injection Grok gérée et évacuer les requêtes du proxy | 409 conflit de propriété du service; 409 `respawnable_service` lorsqu'un wrapper du Planificateur de tâches Windows pourrait relancer le proxy et que l'appelant n'est pas `ocx stop` (rien n'est modifié) ; 409 lorsque le gestionnaire installé refuse de s'arrêter ; 409 `service_state_unknown` lorsque l'état du Planificateur de tâches ne peut pas être lu (rien n'est modifié ; réparez la requête puis réessayez) |
| `GET /api/system/codex-app-server` | Indiquer si les serveurs d'application Codex en cours d'exécution sont antérieurs au catalogue de modèles actuel | — |
| `POST /api/system/codex-restart` | Actualiser le catalogue, puis demander aux serveurs d'application Codex obsolètes de s'arrêter afin que le sélecteur de modèles se recharge | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas |

Expand Down
Loading
Loading