-
Notifications
You must be signed in to change notification settings - Fork 591
feat(codex): a write substrate that survives being interrupted #998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
164 commits
Select commit
Hold shift + click to select a range
87ed409
docs(integrations): test the native-restore thesis against the code, …
lidge-jun 535663c
docs(integrations): Desktop disable is buildable — standard mode is a…
lidge-jun 13f48db
docs(integrations): name the two defects the toggle work actually has…
lidge-jun f375325
docs(integrations): re-plan the unit around desired state, not a roll…
lidge-jun 98a6fb9
docs(integrations): write every implementation phase to diff level, r…
lidge-jun c8c213b
docs(integrations): close audit round 1 — accept all eleven, includin…
lidge-jun 16792a6
docs(integrations): fold every accepted blocker back into the three f…
lidge-jun abb402e
docs(integrations): round 2 fails too — the phase map is the defect, …
lidge-jun 9483d42
docs(integrations): re-slice the roadmap along ownership boundaries
lidge-jun 06179a8
docs(integrations): fold round 3 into WP2 — unknown and incompatible …
lidge-jun 1a76376
fix(clients): stop one rejected modality from poisoning a whole clien…
lidge-jun 6c9ef8e
docs(integrations): WP2 evidence — A/B against gajae's own schema, no…
lidge-jun a0f05af
docs(integrations): fold the WP3 audit — a credential row must not sa…
lidge-jun ee361c8
docs(integrations): WP3 round 2 — replace client vocabulary instead o…
lidge-jun 7b94c63
docs(integrations): WP3 round 3 — wire the fix instead of describing it
lidge-jun d463d45
docs(integrations): WP3 round 4 — one authoritative diff instead of t…
lidge-jun d492bec
docs(integrations): close the two residuals from the WP3 pass
lidge-jun a5e6cde
feat(integrations): API keys leave the client grid for their own row
lidge-jun feaac9c
docs(integrations): WP3 evidence — rendered, observed, and isolated f…
lidge-jun 75d33a1
docs(integrations): re-scope WP4 to Codex alone
lidge-jun b8350d9
docs(integrations): close the WP4 audit — the re-scope held, Codex is…
lidge-jun 5eaa71f
docs(integrations): give WP4 a lock where it had a check
lidge-jun c590714
docs(integrations): WP4 round 2 — the lock is not implementable over …
lidge-jun 99ccfea
docs(substrate): research the four parts of a safe-interruption write…
lidge-jun 7e67a8d
docs(substrate): lock the roadmap — four parts, dependency-ordered
lidge-jun 6ab4d5f
docs(substrate): write all four implementation phases to diff level
lidge-jun 2e8b27d
docs(substrate): a field report that proves two of this unit's criteria
lidge-jun 3130356
docs(substrate): close audit round 1 — a contract phase, because four…
lidge-jun 04f5ac7
docs(substrate): one owner for every surface four authors collided on
lidge-jun d7859bd
docs(substrate): round 2 — I declared ownership without transferring it
lidge-jun 2d5e080
docs(substrate): make the contract complete, so it can actually be co…
lidge-jun ee18274
docs(substrate): collect the contract — four docs become consumers
lidge-jun 47e7cac
docs(substrate): round 3 closes five, and names the one omission behi…
lidge-jun 0f99666
docs(substrate): define what the contract referenced, and stop overcl…
lidge-jun bd1065a
docs(substrate): round 4 — a Critical that would have torn down the r…
lidge-jun a68b4d9
docs(substrate): move transition state into a real coordinator, and s…
lidge-jun 3ec16ed
docs(substrate): every document's TypeScript now compiles, checked pe…
lidge-jun 7bde9e0
docs(substrate): round 5 — the reviewer says the architecture is right
lidge-jun 75aa9b5
docs(substrate): close all three architecture blockers
lidge-jun b97580f
feat(codex): the shared substrate contract, as types
lidge-jun 5c176d7
feat(codex): the substrate's four owned modules
lidge-jun 6b4a798
fix(codex): every history transition update was failing before its CAS
lidge-jun 436e152
fix(codex): close the four C-phase findings against the landed substrate
lidge-jun 96a90fd
feat(codex): the two WP8b primitives WP9 cannot land without
lidge-jun 441f0bf
fix(codex): absence of a marker in config.toml was not native-clean
lidge-jun 4c4dfed
feat(codex): the catalog seam's two primitives, as production exports
lidge-jun 7277371
test(codex): six tests that passed under a broken substrate now fail
lidge-jun 53e5152
fix(codex): the classifier read one catalog filename and demanded a s…
lidge-jun ed08c07
test(codex): five more mutations the strengthened tests still let thr…
lidge-jun d1cee04
fix(codex): a native-tagged history row is not proof the rollout was …
lidge-jun de15caf
fix(codex): the classifier parsed config.toml more strictly than prod…
lidge-jun 63b03c6
docs(substrate): the catalog seam cannot borrow the native generation
lidge-jun db3d69e
test(codex): assert the property, not the example that happened to be…
lidge-jun e8674d6
docs(substrate): a source that was absent is still evidence
lidge-jun 9d7d3f7
feat(codex): the source-evidence boundary the contract already promised
lidge-jun f51404f
test(codex): an allowlist of three aliases is not a property
lidge-jun baa7eae
docs(substrate): the writers we chose to keep were never excluded
lidge-jun 07c68ba
feat(config): the generation guard WP9 is told to stop without
lidge-jun 96c2761
test(codex): every path ended in .json, so a .json check looked general
lidge-jun 4177345
test(restart): a deadline test that read the real clock expected it n…
lidge-jun 364496f
docs(substrate): a lock around the write does not freshen the read
lidge-jun 2db5ee9
docs(substrate): two requests that share a fetch do not share its aut…
lidge-jun 1b32f82
feat(codex): K, and why an opaque permit type cannot hold a lock
lidge-jun e3e43cf
feat(config): looking at the generation should not create it
lidge-jun ea7970a
feat(codex): gather may look at the runtime, not run it
lidge-jun cdb0c8f
feat(oauth): preparing a catalog should not rotate the user's credent…
lidge-jun 1923a2c
test(codex): a fail-fast lock has no guaranteed first-round winner
lidge-jun ba7083f
feat(codex): the catalog writers, behind a permit they must present f…
lidge-jun 2becc77
feat(codex): one owner for every byte gather reads, and for every abs…
lidge-jun 9f7e1a3
docs(substrate): the third cache with the same defect, and the owner …
lidge-jun 471eb5f
docs(substrate): a test helper mutates the registry the flight key ig…
lidge-jun be1ec9b
docs(substrate): discovery policy is its own component, not a combo-g…
lidge-jun 648fbdb
docs(substrate): every test asked whether B may join, none asked what…
lidge-jun cba2482
feat(codex): a shared fetch does not confer shared authority
lidge-jun 8f403a1
feat(codex): the catalog writers we kept now queue behind the same lock
lidge-jun 23a110d
feat(codex): the catalog seam, and the sixteen callers that now use it
lidge-jun 5f76205
fix(codex): runtime authority can move without touching a single cata…
lidge-jun 119a17b
test(codex): race the post-approval seam, and find it never commits
lidge-jun ecef67e
test(codex): the seam does commit — my fixture was the defect
lidge-jun d3882dd
fix(codex): a rotated key could publish rows the old key fetched
lidge-jun 538f8b7
fix(codex): stop enumerating what may share a flight, and hash the wh…
lidge-jun 57c2739
docs(codex): say why the credential term stays after the graph subsum…
lidge-jun 45f7bb7
docs(substrate): WP10 was waiting on a convergeCodex that does not exist
lidge-jun b816851
docs(substrate): the split I chose left a window where a newer restor…
lidge-jun 9e405e2
docs(substrate): the coordinator has no production caller, so no row …
lidge-jun 8a8323f
docs(substrate): observing ENOENT is not the same as having created t…
lidge-jun c1bae49
docs(substrate): a finally block does not run when the process is killed
lidge-jun 554b391
feat(codex): H gets its own database, keyed by the history it guards
lidge-jun 3fd2399
feat(codex): H, held across the database, the manifest and the rollou…
lidge-jun 5e72d35
feat(codex): the history mutation boundary, behind a permit checked a…
lidge-jun 14cc0d4
feat(codex): the history unit, off the caller thread and behind one m…
lidge-jun 37f3fd2
feat(codex): derive the history operation, dispatch it, and join the …
lidge-jun 365707a
fix(codex): a coordinator created by the other process is not unsafe
lidge-jun e90989a
feat(codex): apply routes its history through the Worker instead of t…
lidge-jun e171518
feat(codex): restore takes the Worker too, except where the process i…
lidge-jun aaa17a8
feat(codex): the last two history callers stop writing on their own t…
lidge-jun 86e5d67
test(codex): prove the history writer is unreachable, by walking imports
lidge-jun a358896
docs(codex): WP11 round 6 — the lock has no production caller, and I …
lidge-jun ad58656
docs(codex): merge WP11 into WP12 and rewrite the body, not just the …
lidge-jun fa610fd
fix(acl): the harden memo remembered a pathname, so a replaced file i…
lidge-jun d7f1a8e
docs(codex): another process can retarget the home symlink, so adjace…
lidge-jun 1f83564
fix(acl): dev:ino is not identity on ext4, where an unlinked inode co…
lidge-jun d628e4b
fix(acl): reading the path after icacls answers the wrong question
lidge-jun 5279155
docs(codex): label the round-6 findings as history, and strike the 'N…
lidge-jun 296050e
fix(acl): my fix demanded that icacls not change permissions
lidge-jun cfa1f56
test(acl): the tests covered the twin, not the path production takes
lidge-jun ab9a397
docs(codex): the spec still described the implementation two fixes ago
lidge-jun 9e40a8d
test(acl): the directory half was uncovered too, and that is four pro…
lidge-jun 6d960c9
test(acl): 76 tests proved the primitive; nothing proved production s…
lidge-jun cc6f500
fix(codex): the POSIX chmod was swallowed, and nothing proved a claim…
lidge-jun 1b86f0e
fix(codex): the owner had the same untested default, and threading wa…
lidge-jun d0675aa
test(codex): proving the hardener runs is not proving its failure mat…
lidge-jun 955dd80
test(codex): a denied ACL is a refusal, not contention, and 'it rejec…
lidge-jun f3162b4
test(codex): a terminal snapshot cannot prove something never happened
lidge-jun 53f0f81
test(codex): I wrote the rule and then broke it in the same commit
lidge-jun 04f7ae4
test(codex): the plan said assert the exact target; the tests asserte…
lidge-jun a8b5b64
test(codex): the row that stayed empty longest was the successful one
lidge-jun fc8f480
test(acl): parameterizing one property over four entry points is not …
lidge-jun dfe1bb3
test(codex): 'after the ACL' has to mean after it finished
lidge-jun a6d2d62
test(acl): the retirement branch broke its own four-entry-point rule …
lidge-jun 223e4c9
test(acl): optional does not mean unattributed
lidge-jun da74ae8
feat(codex): the switch now survives a restart
lidge-jun b3acb71
test(acl): closing the shortcut was not the same as closing the axis
lidge-jun 1553b2d
feat(codex): the Codex card gets a switch, and turning it off keeps t…
lidge-jun d80a045
test(acl): 'how the memo can be wrong' was two axes, not one
lidge-jun 10e6ad5
feat(grok): the toggle that already worked now survives a restart too
lidge-jun ea5c5a8
docs(codex): F4 closes at NEAR-PASS; widen the Windows gate and stop …
lidge-jun 480eb3c
feat(codex): N exists — one bounded writer per canonical CODEX_HOME
lidge-jun 5eedd2f
test(codex): C7/C18 — one OS user takes one lock however HOME is spelled
lidge-jun 2ca521a
docs(codex): six audit rounds, and the plan that did not survive them
lidge-jun 6545212
feat(codex): admission compares the bytes it actually read
lidge-jun b41900e
fix(codex): the journal fix that was only a comment
lidge-jun 8d1f236
fix(codex): absence keeps the retryable projection it always had
lidge-jun e0459e4
docs(codex): the lock span is the IO, not the absence of it
lidge-jun 872ea9e
test(codex): the admission fixtures still described the old contract
lidge-jun 6361110
docs(codex): the ownership probe was asking the wrong question
lidge-jun ac7a25d
docs(codex): systemd has its own stale-definition signal, and it is n…
lidge-jun 28f73f2
feat(codex): ownership stops being a hardcoded string
lidge-jun 7405ba4
test(codex): the ownership guard had no test, and a mutation proved it
lidge-jun acca283
fix(service): one service-manager probe, not two
lidge-jun 3dd5c27
wip: checkpoint before mutation checks
lidge-jun 32d50cb
test(service): runLaunchctl carries its exit status now
lidge-jun cce005f
docs(codex): four rounds on one question - what does silence mean
lidge-jun 9c58e7b
docs(codex): the snapshot could not answer even the question I claime…
lidge-jun afdd5a5
docs(codex): the non-gating snapshot does not typecheck as a design
lidge-jun ed64bce
docs(codex): the witness and the span disagreed about when bytes exist
lidge-jun f9a3f1b
docs(codex): the Worker does not derive its operation from intent, an…
lidge-jun b9ff9fd
docs(codex): the operation is derivable, so the schedule is honest af…
lidge-jun 2e3f663
docs(codex): derivable is not persistable, and the row is never resol…
lidge-jun 5740576
feat(codex): the write lock finally has a production caller
lidge-jun afffb02
docs(codex): migration, terminal CAS, and the two unions that do not …
lidge-jun 65782cd
test(codex): the call edge, proven by two real processes contending
lidge-jun 084b63f
docs(codex): the handoff I mentioned and the table I claimed both did…
lidge-jun 667c7de
docs(codex): the table I wrote contradicted the contracts this unit a…
lidge-jun 14cd838
fix(codex): declining to coordinate is not declining to write
lidge-jun 9237f66
docs(codex): the restatement omitted three of 020's rows
lidge-jun 4947e67
docs(codex): the boundary was three gaps, not one
lidge-jun 0fe3472
docs(codex): the close handler must not learn to lie
lidge-jun d4701e0
docs(codex): what leaves this unit, and as what
lidge-jun bf021d7
merge: bring dev onto the write substrate branch
lidge-jun ab4fbf4
test(management): a dev test arrived using a dep this branch removed
lidge-jun 2b9f36c
feat(codex): the transition is resolved, and a dead worker is not a s…
lidge-jun 139fa25
fix(gui): the client id type never learned about codex
lidge-jun 9bf41ad
test(gui): three overview tests assumed one card owned the only switch
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,81 +1,130 @@ | ||
| # Codex and Claude Desktop toggles | ||
| # Client integration switches: Codex, Claude Desktop, and the memory they need | ||
|
|
||
| Split out of `260803_integrations_toggle_all` after its fourth audit | ||
| (`../260803_integrations_toggle_all/007_audit_synthesis_r4.md`). That unit keeps | ||
| Claude Code and Grok, whose toggles need no durable operation state. These two | ||
| need one, and it is the shared dependency four audit rounds kept circling. | ||
| Split out of `260803_integrations_toggle_all` after its fourth audit. That unit | ||
| shipped Claude Code and Grok. This one was scoped around a durable | ||
| operation-state engine — and a research cycle against the real code says that | ||
| engine solves the wrong problem. | ||
|
|
||
| ## Why these two are together and separate | ||
| > **Re-planned 260803 after four research passes.** The rollback engine | ||
| > (`010_operation_state.md`, never written) is **dropped**. Evidence: | ||
| > `001`-`004`. The replacement is smaller, and it also fixes a defect in the | ||
| > toggle we shipped hours ago. | ||
|
|
||
| | | Codex | Claude Desktop | | ||
| |---|---|---| | ||
| | Artifacts | `config.toml`, `opencodex.config.toml`, model catalog, resume history (SQLite) | `<id>.json`, `.bak`, `_meta.json`, four fields in our config | | ||
| | Owner | us, mostly | **Claude Desktop** — we edit another app's registry | | ||
| | Prior state recoverable? | from its own journal, when hashes still match | only from `_meta.json` bytes; `appliedId` was never recorded | | ||
| | Removal code exists? | yes, `restoreNativeCodex` | **no** | | ||
| ## The requirement in the owner's words | ||
|
|
||
| Neither can be undone by re-running its enable path, which is what makes Claude | ||
| Code and Grok cheap. Codex's enable is not the inverse of its disable — the | ||
| journal fallback means a disable may strip fragments rather than restore bytes, | ||
| and a later enable writes today's catalog, not yesterday's arrangement. Desktop | ||
| cannot re-derive which profile the user had selected at all. | ||
| > "스위치를 꺼도 프록시는 살아있어야 돼. 코덱스 말고 다른 것만 켜고 싶을 수도 있잖아." | ||
|
|
||
| So both need an operation record that outlives the request, and that record is | ||
| the first phase. | ||
| Turning a client off must leave the proxy running and serving every other | ||
| client. That is two obligations: the mutation must not stop the proxy, and the | ||
| OFF must survive a restart. | ||
|
|
||
| ## What the research changed | ||
|
|
||
| | Prior claim | What the code says | | ||
| |---|---| | ||
| | Codex needs a durable operation-state engine | `ocx restore` already restores native Codex **without stopping the proxy** (`src/cli/help.ts:18`), and `ocx restore back` is the enable direction. Both exist. (`001`) | | ||
| | Desktop removal is impossible to do safely | Anthropic documents that a selected config without a valid `inferenceProvider` launches **standard mode**. We aim at that instead of guessing. (`002`) | | ||
| | The missing piece is crash recovery | The missing piece is **desired state**. Only Claude Code has one; Grok's shipped toggle is silently re-enabled by the next `ocx start`. (`003`) | | ||
|
|
||
| The fourth research doc (`004`) is an unrelated live defect found while looking: | ||
| one out-of-enum modality value makes a client reject its entire config. It joins | ||
| this unit as an independent work-phase (LOOP-UNIT-CHAIN-01). | ||
|
|
||
| ## Read first | ||
|
|
||
| From the parent unit, all still authoritative: | ||
| In this unit, all four written this cycle: | ||
|
|
||
| - `001_native_restore_thesis.md` — Codex restore, its asymmetries, the history lock | ||
| - `002_desktop_standard_mode.md` — the official standard-mode contract, and why `default` is not the restore verb | ||
| - `003_durable_desired_state.md` — desired vs observed, the Grok regression, the schema | ||
| - `004_export_modality_poisoning.md` — the gjc/Pi enum defect | ||
|
|
||
| - `001_removal_path_inventory.md` — what each client's disable actually costs | ||
| - `003`, `004`, `005`, `007` — the four audit syntheses; `007` is why this unit | ||
| exists | ||
| - `002_consequence_dialog_ux.md` — dialog direction; the Codex and Desktop copy | ||
| lives there | ||
| From the parent unit, still authoritative: `001_removal_path_inventory.md` | ||
| (what each disable costs) and `002_consequence_dialog_ux.md` (dialog direction). | ||
| Its `007_audit_synthesis_r4.md` is the reason this unit exists, but its central | ||
| conclusion is now **superseded** by `001`-`003` here. | ||
|
|
||
| ## Phases | ||
|
|
||
| | Phase | Doc | Deliverable | | ||
| |---|---|---| | ||
| | WP1 | `010_operation_state.md` | The durable operation record: a versioned discriminated journal entry, prepare/commit, restart reconciliation, a field-scoped config writer | | ||
| | WP2 | `020_codex_toggle.md` | Codex disable/enable on top of it | | ||
| | WP3 | `030_desktop_toggle.md` | Desktop removal + rollback, `appliedProfileId` schema work | | ||
|
|
||
| WP2 and WP3 are **parallel siblings**, not a sequence: Desktop does not depend on | ||
| Codex (audit r4 #11). Both depend on WP1 and nothing else. | ||
|
|
||
| ## What WP1 must deliver | ||
|
|
||
| Named by audit round 4, findings #1, #2, #5, #3: | ||
|
|
||
| 1. **A versioned discriminated journal entry.** Today's `JournalEntry` | ||
| (`src/integrations/journal.ts:34-51`) is file-shaped: one `configPath`, one | ||
| `SnapshotRef`, one `resultFingerprint`. A routing description and three | ||
| library members do not fit it. Needs `file-v1 | native-state-v1 | | ||
| desktop-hybrid-v1` with validated serialized state, per-member fingerprints, | ||
| and retention behavior. | ||
| 2. **Prepare/commit with restart reconciliation.** A crash between the mutation | ||
| and the append leaves no undo state at all. Idempotent re-apply does not fix | ||
| this — it helps once a state exists, and none was recorded. The prepared | ||
| record must be durable before the mutation and resolved after. | ||
| 3. **A field-scoped config writer.** `saveConfigPreservingClaudeCode` persists | ||
| the whole live object and its own docstring says a `providers` hand edit is | ||
| clobbered (`src/config.ts:2132-2135`); worse, when disk and caller both | ||
| changed `claudeCode`, the caller's stale subtree wins, so one toggle field can | ||
| clobber a concurrent Desktop-profile edit. Desktop's four fields need a write | ||
| that reloads from disk and touches only named paths. | ||
|
|
||
| ## Carried-forward findings | ||
|
|
||
| Everything in `005` §Carried forward plus round 4's, in particular: Codex's | ||
| pre-state must capture the effective injection mode and history policy, not just | ||
| a routing kind (r4 #4); `injectCodexConfig` takes a concrete `catalogPath`, so | ||
| "selector" needs a resolver (r4 #4); Desktop's ordering is dangling-pointer-safe | ||
| but not transactionally crash-safe and must say so (r4 #6); auto-apply must be | ||
| suppressed while an operation is prepared (r4 #6). | ||
|
|
||
| ## Status | ||
|
|
||
| Not started. `020` and `030` carry their pre-split content and are **stale** | ||
| against the operation-state design WP1 has not written yet; their next P | ||
| re-verifies them against it before any build. | ||
| **Re-sliced after audit round 2** (`006_audit_synthesis_r2.md`). The first map | ||
| sliced along a schema — one ten-key `clientIntegrations` map — which forced every | ||
| phase to touch every client's write path. Two audit rounds widened rather than | ||
| narrowed. This map slices along **ownership boundaries**: one client family per | ||
| phase, each independently auditable. | ||
|
|
||
| | Phase | Doc | Deliverable | Audit state | | ||
| |---|---|---|---| | ||
| | WP2 | `010_modality_boundary.md` | The client-dialect modality filter | **clean through two rounds** | | ||
| | WP3 | `020_api_keys_row.md` | API keys out of the card grid into their own row | never blocking | | ||
| | WP4 | `030_desired_state.md` → re-scoped | Desired state for **Codex only**, plus its gates, single-flight, ownership preflight and CLI semantics | re-scope pending | | ||
| | WP5 | `040_codex_toggle.md` | The Codex switch on WP4's flag | rewrite pending | | ||
| | WP6 | *(new doc)* | Grok's desired state, reusing the shape WP4 proved | not started | | ||
| | WP7 | `050_desktop_toggle.md` | The Desktop switch via documented standard mode | deferred behind WP5 | | ||
|
|
||
| The two pending docs were renamed so the decade order matches the phase order: | ||
| reading the unit lexicographically now gives the build order, which is the whole | ||
| point of the numbering convention. | ||
|
|
||
| WP1 was the research cycle plus this roadmap, twice audited. | ||
|
|
||
| WP2 and WP3 depend on nothing here and on each other not at all — they ship | ||
| first, alone, because each changes one thing at one boundary. That property is | ||
| the only thing that survived both audit rounds intact. | ||
|
|
||
| Three deliberate exclusions, each an accepted audit finding rather than a | ||
| convenience: | ||
|
|
||
| - **Claude Code's ingress gates are not touched at all.** Round 1 #1 established | ||
| that `claudeCode.enabled` is the documented kill switch for `/v1/messages`; | ||
| the honest conclusion is not to route it through a new helper in this unit. | ||
| - **The six file clients get no desired-state flag here.** Round 2 #4 (existing | ||
| explicit OFF choices are not migrated) and #5 (a mutating GET) both belong to a | ||
| phase that does not exist yet, and inventing it under audit pressure is what | ||
| produced round 2's new findings. | ||
| - **Desktop is behind Codex, not beside it.** Round 2 #3 found no coherent rule | ||
| when a foreign profile is selected. The goal explicitly permits an evidenced | ||
| deferral; this is one. | ||
|
|
||
| ## Scope boundary | ||
|
|
||
| IN: `src/clients/config-export.ts`, `src/types.ts`, `src/config.ts`, | ||
| `src/codex/sync.ts`, `src/grok/sync.ts`, `src/cli/index.ts`, | ||
| `src/cli/opencode.ts`, `src/claude/desktop-3p.ts`, | ||
| `src/server/management/native-integration-routes.ts`, | ||
| `src/server/management/agent-settings-routes.ts`, `src/server/management-api.ts`, | ||
| `gui/src/pages/integrations/*`, `gui/src/styles-integrations.css`, | ||
| `gui/src/i18n/*`, `tests/`, `gui/tests/`. | ||
|
|
||
| OUT: releases, publishing, deploys, tags; starring the repository; rewriting the | ||
| six-client file machinery in `src/integrations/`; `docs-site` restructuring; | ||
| recording the previous `appliedId` (deferred, `002` §Residual). | ||
|
|
||
| ## Criteria | ||
|
|
||
| - C1 — gjc loads our emitted config with no schema error, proven from the real | ||
| file; Pi's identical exposure is closed in the same change. | ||
| - C2 — Codex stays disabled across a proxy restart, an `ocx ensure`, and a | ||
| `POST /api/sync`. | ||
| - C3 — an upgrading user with no `clientIntegrations` key sees no behavior change. | ||
| - C4 — disabling Codex never stops the proxy and never closes `/v1/responses`. | ||
| - C5 — Codex toggles both directions from the overview with the proxy running. | ||
| - C6 — a Codex disable blocked by the held history DB is an explained refusal | ||
| naming the cause, never a raw 500 and never a false green. | ||
| - C7 — startup convergence never touches state owned by a service running from a | ||
| different `OPENCODEX_HOME` (round 2 #2, blocking). | ||
| - C8 — API keys render as a row above the grid, observed rendered. | ||
| - C9 — typecheck, full test, gui lint, gui test, privacy scan all green. | ||
| - C10 — Desktop ships with a proven restore path OR is deferred with recorded | ||
| evidence. Both outcomes close this criterion. | ||
|
|
||
| ## Risk register | ||
|
|
||
| | Risk | Mitigation | | ||
| |---|---| | ||
| | A gate silently unplugs a working client on upgrade | Absent key means ON, everywhere, with a test for the absent-config case (`003`) | | ||
| | Gating a safety path | Explicit do-not-gate list: journal repair, ownership checks, owned teardown, shared transports (`003`) | | ||
| | Desktop pointed at a missing file | Never delete `appliedId`, never leave it dangling, never pick an entry by the name `Default` — this machine's `Default` is already dangling (`002`) | | ||
| | The modality fix erases valid internal metadata | Filter at the client-dialect boundary only; management and CLI keep carrying `audio` verbatim (`004`) | | ||
| | A green suite hides the real failure | 91 tests pass today beside a config gjc refuses to load. Every criterion names a live artifact, not a unit test | | ||
| | A repair introduces a worse defect than the one it fixes | Round 2 produced exactly this. One client family per phase, re-audited before the next starts — never a cross-client rewrite under audit pressure | | ||
| | Convergence tears down a foreign home's state | Every native remover runs `assertNativeTeardownOwned` before removal, and reconciliation runs only after service-ownership resolution (round 2 #2) | |
144 changes: 144 additions & 0 deletions
144
devlog/_plan/260803_codex_desktop_toggle/001_native_restore_thesis.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| # The native-restore thesis, tested against the code | ||
|
|
||
| Research doc. No diffs here — the implementation designs live in the decade | ||
| docs. This one records what the code actually does, because the previous | ||
| unit's plan was built on a claim the code does not support. | ||
|
|
||
| ## The claim under test | ||
|
|
||
| `000_plan.md` asserts that Codex and Claude Desktop both need a durable | ||
| operation-state engine — a versioned discriminated journal entry, prepare/commit | ||
| with restart reconciliation, and a field-scoped config writer — before either | ||
| can get a toggle. The owner's counter-thesis: the proxy keeps RUNNING while a | ||
| client is switched back to its native path, so each client only needs to be | ||
| returned to a path that already exists, not replayed from a recorded snapshot. | ||
|
|
||
| For Codex the counter-thesis is correct, and the evidence is not subtle. | ||
|
|
||
| ## Codex: the toggle pair already ships as a CLI | ||
|
|
||
| `ocx restore` is documented in `src/cli/help.ts:18-20` as: | ||
|
|
||
| > Restore native Codex config without stopping the proxy; `restore back` | ||
| > re-points codex at the running proxy. | ||
|
|
||
| That is the toggle, both directions, with the proxy up. `src/cli/index.ts:770` | ||
| calls `restoreNativeCodex()` with no lifecycle operation anywhere near it, and | ||
| `src/cli/index.ts:756` implements the enable direction as `syncModelsToCodex(live.port)` | ||
| against a proxy it first proves is live via `findLiveProxy()` (`:751`). | ||
|
|
||
| Stronger still: `POST /api/stop` (`src/server/management-api.ts:181`) calls | ||
| `restoreNativeCodex()` FIRST and only then schedules the drain and exit. The | ||
| restore therefore already executes while the listener is serving. Whether the | ||
| proxy later stops is irrelevant to the restore itself. | ||
|
|
||
| The service-stop path does verify the listener is gone before restoring | ||
| (`src/service.ts:2571`), but that check enforces the requested outcome "service | ||
| stopped" so success is not claimed while a supervisor respawns the process. It | ||
| is not a precondition inside `restoreNativeCodex()`. | ||
|
|
||
| **Conclusion:** no durable operation-state engine is required to make Codex | ||
| switchable. The disable path exists, the enable path exists, and both are | ||
| proxy-agnostic. | ||
|
|
||
| ## What restore actually touches, and where it is not symmetric | ||
|
|
||
| Four state groups, when no external `model_provider` owns the config: | ||
|
|
||
| | Group | Restore behavior | Evidence | | ||
| |---|---|---| | ||
| | `config.toml` + `opencodex.config.toml` | Byte-exact from the journal when the injected hash still matches; otherwise strip owned fragments | `src/codex/journal.ts:109`, `src/codex/inject.ts:770` | | ||
| | Injection journal | Deleted on a complete restore, retained on a partial one | `src/codex/journal.ts:133` | | ||
| | Model catalog | Pristine backup + post-sync native additions, or drop slash-qualified routed rows keeping native ones | `src/codex/catalog/sync.ts:572-590` | | ||
| | Resume history | May update `state_5.sqlite`, patch/append rollout JSONL, consume the backup manifest | `src/codex/history-provider.ts:413,656,691` | | ||
|
|
||
| When an external provider such as `custom` owns `model_provider`, restore removes | ||
| only the stale journal and deliberately leaves everything else alone | ||
| (`src/codex/inject.ts:765`). That is a pre-existing courtesy to a user who moved | ||
| off us by hand, and the toggle must preserve it rather than "fixing" it. | ||
|
|
||
| Three asymmetries matter for the toggle's honesty: | ||
|
|
||
| 1. **Enable is `syncModelsToCodex()`, not `injectCodexConfig()`.** Injection | ||
| selects and writes a catalog path but does not build the routed rows; | ||
| `src/codex/sync.ts:83-110` refreshes the catalog and then injects, and that | ||
| is what `ocx restore back` uses. A toggle wired to bare injection would | ||
| re-point Codex at a catalog that no longer lists the routed models. | ||
| 2. **A post-injection root model selection is destroyed, not restored.** If the | ||
| user edited config after we injected, the hash no longer matches, the | ||
| fallback strip runs, and a root `model = "provider/slug"` line is removed | ||
| (`src/codex/inject.ts:315,700`). Re-enabling has no record of that selection. | ||
| The dialog copy must not promise to put it back. | ||
| 3. **Resume history is reversible but not byte-identical.** Restore patches line | ||
| one when safe and appends a `session_meta` | ||
| (`src/codex/history-provider.ts:81,444`); re-enabling appends another | ||
| provider change rather than deleting that history. | ||
|
|
||
| ## The history lock, and why the current return shape is not enough | ||
|
|
||
| The write path mirrors Codex's five-second SQLite busy timeout and retries twice | ||
| with a 500 ms delay (`src/codex/history-provider.ts:25,526`). A Codex app or IDE | ||
| holding the WAL writer lock is exactly what makes it fail. | ||
|
|
||
| At the low-level boundary the failure is structured: | ||
|
|
||
| ```ts | ||
| return withHistoryRetry(...) ?? { rows: 0, files: 0, failed: true }; | ||
| ``` | ||
|
|
||
| Recoverable busy/lock/permission failures return `failed: true`; corruption and | ||
| programming errors throw (`src/codex/history-provider.ts:511,536,577`). | ||
|
|
||
| But `restoreNativeCodex()` discards that structure. `history.failed` only edits | ||
| the message string, and `success` stays `cfg.success` | ||
| (`src/codex/inject.ts:787,794`). A config restore that succeeded while the | ||
| history stayed locked returns roughly: | ||
|
|
||
| ``` | ||
| { success: true, message: "... history could NOT be restored ..." } | ||
| ``` | ||
|
|
||
| So a GUI that trusts `success` reports a clean disable while routed threads stay | ||
| tagged `opencodex` and remain invisible in the native app. And `failed: true` | ||
| itself conflates lock contention with `EPERM`/`EACCES`. | ||
|
|
||
| **Design consequence for `020`:** the toggle route must not consume | ||
| `restoreNativeCodex()`'s boolean. It needs the structured per-artifact outcome — | ||
| config, catalog, history — with the history failure carrying a classified reason, | ||
| so the card can render "disabled, but your routed threads are still hidden | ||
| because the Codex app is holding the history database" instead of a green check | ||
| or a raw 500. Parsing the message string is not acceptable. | ||
|
|
||
| ## The real hazard is not the socket | ||
|
|
||
| The sibling explorer's central finding, and the one that reshapes this unit: | ||
| **there is no persisted per-client desired state.** After a disable, the next | ||
| proxy startup, `ocx ensure`, `ocx sync`, a provider/model mutation, or | ||
| `POST /api/sync` can silently re-inject Codex (`src/cli/index.ts:318,365`, | ||
| `src/server/management/config-routes.ts:261`). | ||
|
|
||
| That is the actual engineering work this unit needs — and it is a much smaller | ||
| thing than the operation-state engine `000_plan.md` specified. A durable | ||
| desired-state flag per client, defaulting ON so no existing setup dies on | ||
| upgrade, consulted by every automatic apply path. It is a switch's memory, not a | ||
| rollback engine. | ||
|
|
||
| `030` and the durable-state design pick this up; `010` is retired as specified | ||
| because the crash-recovery machinery it described is not what the evidence asks | ||
| for. | ||
|
|
||
| ## Observable consequences of a Codex disable | ||
|
|
||
| What the consequence dialog must actually name: | ||
|
|
||
| - plain `codex` returns to its native provider path (`src/codex/inject.ts:688`) | ||
| - routed `provider/model` catalog rows disappear; native rows survive | ||
| (`src/codex/catalog/sync.ts:578,590`) | ||
| - the generated `opencodex` profile is removed or replaced, and managed native | ||
| subagent defaults are restored or stripped (`src/codex/inject.ts:703`) | ||
| - previously routed threads are retagged to their native provider — or stay | ||
| hidden if the history DB was locked (`src/codex/history-provider.ts:677`) | ||
| - the proxy keeps serving Claude, Grok, the exported file clients, and direct API | ||
| callers, because restore contains no lifecycle operation at all | ||
|
|
||
| That last line is the whole point of the feature. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Update the retired work-package reference.
Lines 105-110 point the structured Codex outcome at
020. That document is retired. The live roadmap assigns the Codex toggle to040_codex_toggle.md.The same numbering drift appears in
050_desktop_toggle.md, which calls itself WP6 and references020_desired_state.md, while000_plan.mdassigns Desktop to WP7 and desired state to030_desired_state.md. Align these references before implementation.🤖 Prompt for AI Agents