Skip to content

Run PortOS's App Management update through the detached launcher - #5985

Merged
atomantic merged 3 commits into
mainfrom
claim/issue-5976
Sep 3, 2026
Merged

Run PortOS's App Management update through the detached launcher#5985
atomantic merged 3 commits into
mainfrom
claim/issue-5976

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Updating PortOS from the App Management page stopped portos-server and never brought it back, even when the pull was a no-op.

Summary

  • Root cause. PortOS is itself a managed app, so that button reaches update.sh through appUpdater's plain spawn, not routes/update.js. update.sh's own pm2 delete ecosystem.config.cjs tree-kills portos-server — and PM2 walks PPID, so it takes the still-attached script down with it, mid-delete and long before the closing pm2 start. The fingerprint was the partial deletion: only portos-server and portos-cos, the two entries declared before the script died, went missing.
  • Fix. The PortOS record now delegates to updateExecutor.executeUpdate(), which already double-forks via spawnDetached for the self-update route — rather than keeping a second detached-spawn implementation in sync. appUpdater also skips its trailing restart step for that case, since update.sh starts the ecosystem itself.
  • Scoped narrowly. Delegation applies only to the PortOS record running this checkout's standard script. A custom updateCommand, or a repoPath that is not this checkout, keeps the attached path so we never silently run a different script than the one configured — and that decision is logged, since the attached path is the one that failed. The path comparison resolves symlinks and case-folds on macOS/Windows, because repoPath is user-editable and not force-synced. Non-PortOS managed apps are untouched.
  • Shared update lock. The delegated launch takes setUpdateInProgress(true) — the same atomic lock POST /api/update/execute takes, so the two entry points cannot launch update.sh concurrently, and the flag CoS spawn gates read (Gate CoS spawn engines on updateInProgress to close self-update race #4124) is now up before the script deletes portos-cos.
  • Defense in depth. Both platform scripts close with a verify step that polls /api/system/health (new scripts/verify-server-health.js) until it reports ok, and on timeout spends one more pm2 start before logging the manual recovery command. When it still fails, they print "Update applied, but PortOS is DOWN" and exit non-zero — the script outlives the server it restarts, so its exit status is the only signal a caller has. Nothing previously noticed "I stopped the server and it did not come back", which is what turned a no-op update into hours of downtime.

Test plan

  • server/services/appUpdater.test.js — 15 tests: PortOS goes through the detached executor and never bufferedSpawnOrThrow; the lock is taken before the launch and a second update is refused; the restart step is skipped; a differently-spelled repoPath still delegates; a custom updateCommand, a repoPath outside this checkout, and every non-PortOS app keep the attached spawn and their own restart + dashboard handoff; a failed update surfaces instead of reporting success.
  • scripts/verify-server-health.test.js — 7 tests: URL ordering and dedupe, only a 200 reporting status: "ok" counts, a dead port fails, the poll/timeout contract on injected time, and PORTOS_HEALTH_WAIT_MS sentinel parsing.
  • Full server suite green (38032 passed). Full client suite green (10089 passed). npm run lint clean in client.
  • Shell/PowerShell edits syntax-checked; update.sh verified under bash -n.

Deferred

POST /api/update/execute's remaining preflight refusals (live CoS agent, persistent-mind image work, fork acknowledgement) are still absent from the socket path. They need appUpdateSchema and UI work to carry the acknowledgements, and are filed as #5984.

Closes #5976

@atomantic

Copy link
Copy Markdown
Owner Author

Required code review was not completed before publication. This PR is intentionally left open and will not be merged until the required review completes.

@atomantic

Copy link
Copy Markdown
Owner Author

Review status for this run (configured reviewers: codex,claude):

  • codex — review-blocked. Failed on its first invocation with ERROR: You've hit your usage limit (account-level; the CLI is healthy and on PATH). No verdict was produced, and no self-review was substituted in its place.
  • claude — clean after 2 rounds. Round 1 raised 6 findings: the delegated launch not taking the self-update lock (so the Gate CoS spawn engines on updateInProgress to close self-update race #4124 CoS spawn gate stayed inert), an unnormalized repoPath compare that silently re-armed the attached spawn, PORTOS_HEALTH_WAIT_MS=0 collapsing into the 120s default, both scripts printing "Update Complete" and exiting 0 after the verify failed twice, the dashboard handoff being dropped for the paths that still restart PortOS from appUpdater, and a test pinning the poll loop's internal pass schedule rather than its timeout contract. All six are fixed in dddc38a36. The round-2 convergence check confirmed each fix and raised 2 low findings (a decline log naming the wrong reason, and a docs sentence crediting non-PortOS apps with a dashboard handoff they never had), fixed in 4d29818ea.

Per this run's required-review rule, the missing codex verdict blocks the merge.

…#5976)

Updating PortOS from App Management stopped portos-server and never brought
it back, even when the pull was a no-op.

PortOS is itself a managed app, so that button reaches update.sh through
appUpdater's plain spawn rather than routes/update.js. update.sh's own
`pm2 delete ecosystem.config.cjs` then tree-kills portos-server — and PM2
walks PPID, so it takes the still-attached script down with it, mid-delete
and long before the closing `pm2 start`. The fingerprint was a partial
deletion: only portos-server and portos-cos, the two entries declared before
the script died, went missing.

updateExecutor already solved this for the self-update route with
spawnDetached's double-fork, so the PortOS record now delegates to
executeUpdate() instead of keeping a second detached-spawn implementation in
sync. appUpdater also skips its trailing restart step for that case, since
update.sh starts the ecosystem itself. The delegation is narrowed to the
PortOS record running this checkout's standard script: a custom
updateCommand, or a repoPath pointing elsewhere, keeps the attached path so
we never silently run a different script than the one configured. Non-PortOS
managed apps are untouched.

Defense in depth: both platform scripts now close with a `verify` step that
polls /api/system/health until it reports ok, and on timeout spends one more
`pm2 start` before logging the manual recovery command. Nothing previously
noticed "I stopped the server and it did not come back" — the process that
would have noticed is the one that did not come back.

Removes appUpdater's dashboard handoff, which the delegation makes
unreachable; update.sh runs open-ui-in-browser.js itself, with its own
health-wait and navigate retry.

Closes #5976
…5976)

Review follow-ups on the App Management self-update path.

The delegated launch now takes `setUpdateInProgress(true)` before starting
update.sh. That flag is the same atomic lock POST /api/update/execute takes,
so the two entry points can no longer launch the script concurrently — and
it is what subAgentSpawner, agentLifecycle and persistentMindSupervisor gate
on, so holding it also stops a CoS agent from being spawned into a process
the script is about to `pm2 delete` (#4124). The route's remaining
acknowledgement-bearing preflight guards need schema and UI work and are
tracked in #5984.

The `repoPath == PATHS.root` test that decides whether to delegate now
resolves symlinks and case-folds on macOS/Windows. repoPath is user-editable
and not force-synced, so a trailing slash or a different spelling of the same
checkout would otherwise silently fall back to the attached spawn — re-arming
the exact failure this fixes. The fallback is logged rather than silent.

update.sh / update.ps1 no longer print "Update Complete" and exit 0 after the
health verify failed twice. They print "Update applied, but PortOS is DOWN"
and exit non-zero: the script outlives the server it restarts, so its exit
status and the tail of data/update.log are the only signals a wrapper or an
operator still has.

Restores appUpdater's dashboard handoff. It is redundant for the delegated
case, which update.sh handles, but a PortOS record with a custom updateCommand
still restarts from here and would otherwise never reopen the dashboard.

Also stops PORTOS_HEALTH_WAIT_MS=0 (and a typo'd value) collapsing into the
120s default, and drops a health-poll test assertion that pinned the loop's
internal pass schedule rather than its timeout contract.
…path (#5976)

The decline log fired even when no update routine ran at all, and reported
'a custom update command is configured' for a record that had none — sending
an operator after the wrong misconfiguration. It now fires only where the
routine actually runs, and distinguishes all three narrowings.

Also corrects a docs sentence that credited non-PortOS managed apps with a
dashboard handoff; that handoff opens the PortOS dashboard and returns early
for every other app.
@atomantic
atomantic merged commit 5588b14 into main Sep 3, 2026
12 checks passed
@atomantic
atomantic deleted the claim/issue-5976 branch September 3, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PortOS update from App Management leaves the server dead: update.sh is tree-killed at its own pm2 delete

1 participant