From 793f3b157d5e620f2224ed2d7b5d902787f08bcc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 4 Nov 2026 11:07:57 -0600 Subject: [PATCH] fix(cli): upgrade and roll back Case 2 proxy hubs (#772) --- BUILD-LOG.md | 1 + docs/SESSIONS.md | 1 + docs/guides/host-run-sheet.md | 8 +- packages/cli/tests/stack-upgrade.test.mjs | 141 +++++++++++++++++++++- scripts/stack-upgrade.sh | 47 ++++++-- 5 files changed, 179 insertions(+), 19 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index ee7a3608a..46350921f 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6016,3 +6016,4 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-04T16:35:38Z | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 before/after | Before: fast-forwarded the assigned worktree to reviewed 52b283c8a; C1 foreign working-repo retry adoption/internal object links, C2 stale records and intent commits, C3 broker serving before the activate receipt. After: a first-attempt foreign repository gets a durable refused working receipt; recovery requires this plan's ownership claim, created exclusively inside the init directory before rename, as well as the existing ref checks. Every working-repository Git child checks for internal links and alternates; bootstrap also rechecks between its recovery reads. The existing kill-and-adopt recovery remains green. Organization holdAsync keeps the existing on-disk lock through asynchronous effects and releases it on success or failure; records initialization, intent publication/add/commit and intent rollback stay protected, while the bootstrap's exact-add mapping keeps its own transaction. The complete marker is verified and the activate receipt commits before the broker reload; runtime no longer overrides pending admission, and receipt I/O failure stays fenced until a fault-free retry. RED on 52b283c8a with final tests overlaid: 79 existing tests pass and all 16 new regressions fail as expected across request-bootstrap, project-bootstrap and working (95 total, zero skips); all five supplied reproducers were ported with corrected assertions. Final gates, all zero failures/skips/cancellations: focused 103/103 (baseline 90), bus 1499/1499 (1488), business 307/307 (305), project+seat 274/274 (271), launcher+stack-release including external-release cases 286/286 (286). Gate 5 uses node --test packages/cli/tests/launcher.test.mjs packages/cli/tests/stack-release*.test.mjs because these are CLI test files, not separate packages. Corrections: an initial gate-5 command named nonexistent package directories and ran no tests; an intermediate bus run failed the newly added per-child C1 regression, also reproduced on the base and fixed before the final green rerun. An earlier candidate gate-5 run was superseded and only its own descendant processes stopped; the final run completed. No preexisting test failure was found. Evidence: /mnt/storage/scratch/tmp/qRkZh1/ (before-1..4.log, before-5-cli.log, after-1..5.log, red-final-snapshot.log, verified-files.sha256). Provider interruption recovered from complete logs and matching source/test hashes. The queue refuses linked worktrees, so the explicit controller assignment was followed without changing the canonical queue. One local commit only; no push, live-instance action, role/contract/migration edit or residual C1/C2/C3 finding. 2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access. +2026-11-04T17:07:37Z | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Before: c098fa022 rejects the bare --proxy args line; read_old and stop/recovery cover only bus and Console, so a proxy hub cannot upgrade or roll back. After: accept only the bare flag, require all selected installed units to name one copy, refuse missing/linked/malformed/disagreeing proxy units and an installed proxy without its flag before stopping anything; stop Console, bus, proxy, wait for each, and preserve --proxy through both new and old renders. A2f starts proxy first and refuses a failed proxy restart with all three stopped; all rollback-failed cleanup includes the proxy. Header exit-code meanings and the ordinary two-unit path remain unchanged. Section 7 documents the flag and lifecycle. Nine added tests cover upgrade, explicit rollback, failed-upgrade restoration of all three unit files, A2f recovery/start failure, final stopped cleanup, no-proxy compatibility, malformed args, installed-unit refusals, and the real factory renderer re-pin/rollback. RED: focused #772 3 pass/5 fail/0 skip on the base, all five failures at args-unreadable --proxy. Corrections: first GREEN candidate 7/1/0 exposed the render stand-in using a different checkout path; a --checkout override was unsupported (6/3/0), so the stand-in now runs from the same fixture clone as setup; final focused 9/0/0. Required final gates (pass/fail/skip): node --test packages/cli/tests/stack-upgrade.test.mjs 34/0/0; node --test packages/cli/tests/case1-accept.test.mjs 53/0/0; node --test packages/cli/tests/case2-install.test.mjs 28/0/0; node --test packages/cli/tests/case2-unit.test.mjs 88/0/0; bash scripts/test-case2.sh 21/0/0. The four matching test files were found with git grep. Initial case2-install 6/22/0 lacked node_modules/yaml; initial scratch acceptance 13/8/0 lacked the installed dependency tree and passed after lockfile-only npm ci --ignore-scripts with distinct empty user/global configs and a scratch cache. The first npm setup attempt refused duplicate /dev/null config paths before installation. Every test invocation set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX. bash -n and git diff --check passed. Logs: /mnt/storage/scratch/tmp/JBjOT3/ (RED/intermediate/focused and all required gate logs, npm setup, verified-source.sha256). Queue next refused the linked worktree; canonical queue movement remains with the coordinator. Fixture systemctl/sudo and synthetic configs only; no live host/unit, real credentials, ~/.config/mosaic-*, ~/.mosaic or /srv/mosaic access. One local commit authorized; no push, merge or deployment. No remaining code limitation; live .53 acceptance is outside this assignment. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 53a28fe5a..4f3391b44 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2004,3 +2004,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/harden-project-bootstrap-executor | #802 bootstrap executor hardening | N1 working path confined before and inside the helper, I1 guarded() makes the freshness check and every protected write one synchronous run under the organization lock, I2 complete marker published before activation and readiness needs marker plus committed receipt at boot, reload and admission. Brief trio 90/90, bus 1488/1488, business 305/305, project+seat 271/271, launcher+stack-release 261/261, zero skips; no schema change; local commit only, no push. 2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/bootstrap-hardening-fix-802-1 | #802 bootstrap hardening fix round 1 | C1 permanent foreign-repo refusal, exclusive ownership claim and per-child internal-link/alternates checks; C2 async-held organization lock through records init, intent commit and rollback; C3 committed activate receipt before broker reload, fenced receipt failure and successful retry. All 16 added regressions fail on 52b283c8a; final focused 103/103, bus 1499/1499, business 307/307, project+seat 274/274, launcher+release including external cases 286/286, zero skips. Logs /mnt/storage/scratch/tmp/qRkZh1/; one local commit, no push, no role/contract/migration/live changes. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push. +2026-11-04 | Codex implementation sub-agent, t3/placeholder | #772 / row 334 F2 Case 2 proxy upgrade and rollback | Proxy args, three-unit copy checks and ordered stop/re-render/start/recovery; run-sheet section 7 updated. Focused 9/9; required upgrade 34/34, case1-accept 53/53, case2-install 28/28, case2-unit 88/88, scratch acceptance 21/21, zero skips; lockfile dependencies restored after initial setup failures. Evidence /mnt/storage/scratch/tmp/JBjOT3/. Linked-worktree queue refusal left to coordinator; local commit only, no push or live/secret access. diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 3209cb5ca..19e746abe 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -377,9 +377,15 @@ place the correct project file and repeat the check above before continuing (Duc line holding `--`. Root writes it at the unit render, from the values the go names. The stack's user's install never writes it: that user can't write `/etc`, and root reads nothing that user wrote. It must be root's, with no group or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`, -`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the +`--copy` (exactly once), `--node`, `--profile`, `--claude`, `--pm` and `--proxy`. `stack-upgrade.sh` changes only the `--copy` line, and only after confirm passes. +For a hub installed with `--proxy`, keep that bare flag on its own line before `--`; it takes no value. +Upgrade and rollback require the proxy, bus and Console units to name the same copy. They stop the Console, +then the bus, then the proxy, re-render all three onto the selected copy, and start the proxy, then the bus, +then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or +disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops. + For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh` explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory` diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index a0f1ef823..c98571690 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -46,8 +46,8 @@ function fakeSudo(K){ } // case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its -// WorkingDirectory under the stack's copies) and starts the bus, then the Console, as case2-unit.sh -// does. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs +// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console, +// as case2-unit.sh does. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs // probe.sh, a second driver, and records its exit and stderr. function fakeRender(K){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'); @@ -61,10 +61,15 @@ function fakeRender(K){ fs.writeFileSync(path.join(sd,'probe.json'),JSON.stringify({code:p.status,err:p.stderr})); } if(fs.existsSync(f(`render-fails-${copy}`))){console.error(`fixture render: ${copy} fails`);process.exit(1)} - const sh=(n,...x)=>cp.execFileSync('bash',[path.join(K.repo,'scripts',n),...x],{stdio:['ignore','ignore','inherit']}); + const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']}); sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude')); sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve); - for(const u of [`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'])cp.execFileSync(path.join(K.sbin,'systemctl'),['start',u],{stdio:'inherit'}); + const names=[`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service']; + if(pre.includes('--proxy')){ + fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`); + names.unshift('mosaic-proxy.service'); + } + for(const u of names)cp.execFileSync(path.join(K.sbin,'systemctl'),['start',u],{stdio:'inherit'}); } // Root's systemctl: the fixture's, with systemd's no-op start of an active unit, and a one-shot @@ -75,6 +80,7 @@ function fakeSystemctl(K){ fs.appendFileSync(path.join(sd,'root-systemctl.log'),a.join(' ')+'\n'); const noop=path.join(sd,'faults',`stop-noop-${a[1]}`); if(a[0]==='stop'&&fs.existsSync(noop)){fs.rmSync(noop);process.exit(0)} + if(a[0]==='start'&&fs.existsSync(path.join(sd,'faults',`root-start-fails-${a[1]}`)))process.exit(1); if(a[0]==='start'){let s={};try{s=JSON.parse(fs.readFileSync(path.join(sd,'units.json'),'utf8'))}catch{}if((s[a[1]]||{}).state==='active')process.exit(0)} const r=cp.spawnSync(path.join(K.sbin,'systemctl'),a,{stdio:'inherit'});process.exit(r.status??1); } @@ -141,7 +147,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text); // The step log: every line `