Skip to content

feat(daemon): offer operator-initiated restart to adopt an installed build (lr-e85fec) - #420

Merged
clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-e85fec-daemon-build-adoption
Sep 7, 2026
Merged

clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-e85fec-daemon-build-adoption

Conversation

@clagentic-builder

Copy link
Copy Markdown
Contributor

What changed

Gives the daemon a code path to detect that a newer build has been installed on disk than the one it loaded at startup, and offer the operator a restart through the product UI. Closes the gap lr-e85fec describes: a merged, tested, installed fix (lr-b75006) sat inert because nothing in the product told the operator a restart would apply it.

Design decision (lr-e85fec ranked options 1-3)

  • Option 1 (fully automatic, session-preserving hot-reload) investigated and rejected as infeasible in this codebase, not skipped: session state (live child processes via YOKE adapters, open WebSocket connections, terminal PTYs, in-flight tool-approval promises) are live JS objects/OS handles in-heap; Node has no supported require() unload/reload primitive. The only re-exec path that exists today (spawnAndRestart(), used by the existing admin Restart action) already demonstrates this -- it tears down every project via shutdownProjects() before spawning a replacement process. Making a re-exec survive a live tool call or open terminal needs mid-turn checkpoint/restore -- lr-0287 Tier 2, explicitly out of scope in that task and not built.
  • Built option 2: an OFFERED, operator-initiated affordance. Detection (new lib/build-update-check.js) polls the on-disk build-sha.json every 5 minutes against loadedBuildSha (captured once at daemon startup, lr-dc9a3b) and, on a mismatch, broadcasts a diagnostic with actionable:{label, action:restart_for_build_update} -- the SAME diagnostic channel lib/memory-shed.js already uses, now with a real clickable button instead of just a hint icon. The button sends a new restart_for_build_update WS message, admin-gated identically to restart_server/shutdown_server. Its handler waits for in-flight sessions to finish (waitForIdleThenAct, bounded 60s, same getActiveLiveCount() signal drain.js already uses) before calling the existing spawnAndRestart().
  • Option 3 (lr-22e8 stale-inode/journald WARN + health.stale) unchanged and still fires -- this adds the actuator on top of it.

FORBIDDEN constraints honored: no post_merge_step, no timer, no automatic/side-effect restart anywhere in this diff. The only caller of spawnAndRestart() from this new path is the WS handler that fires in direct response to the operator clicking the button.

Why

lr-e85fec (P1, filed by holden): a merged, tested, installed fix for a broken permission Allow/Deny safety control (lr-b75006) sat inert. lr-22e8 (closed June) already added a detector with no actuator, and that alone was not enough three months later.

Files changed

  • lib/build-update-check.js (new) -- checkForNewerInstalledBuild() and waitForIdleThenAct(), unit-tested with an injected fake clock.
  • lib/daemon.js -- periodic poll wired into startListening; onRestartForBuildUpdate handler.
  • lib/project-sessions.js -- restart_for_build_update WS message, admin-gated like restart_server.
  • lib/public/modules/diagnostics.js, lib/public/css/diagnostics.css -- shared _buildActionableEl() renders a real button for a known actionable.action behind a client-side allowlist (ACTIONABLE_HANDLERS); toast auto-dismiss suppressed when an action button is present.
  • docs/guides/architecture.md -- new Build-adoption diagnostic section.
  • test/build-update-check-lr-e85fec.test.js (new) -- 27 tests.

Verification

  • Read lr-e85fec, lr-0287, lr-22e8, the cited engrams, and docs/guides/architecture.md in full before writing code.
  • Read lib/drain.js + tests, lib/memory-shed.js, scripts/verify-installed-build.js + lib/daemon.js get_build_status, and lib/project-sessions.js restart_server/shutdown_server in full before reusing/mirroring their patterns.
  • npm test: full suite green, 1603/1603 passing, 0 failures on the final branch state.
  • Did not restart or attempt to restart any running service as part of this work.

What this does not claim

This does not itself demonstrate the lr-b75006 stale-daemon condition fixed live on PID 604966 -- that requires deploying this PR and observing the real daemon offer and accept the restart, out of scope pre-merge. Left open for NAOMI/operator verification post-merge, per this repo's own reports-success-while-nothing-happened discipline (retro tome #845).

Task: lr-e85fec

…sub-agent permission on task_notification (lr-b75006)

fix(sdk-message-processor): preserve activeTaskToolIds for a pending sub-agent permission on task_notification (lr-b75006)
Adds lib/build-update-check.js: checkForNewerInstalledBuild() compares
the SHA a running daemon loaded at startup against a fresh read of
lib/build-sha.json, and waitForIdleThenAct() is a small, unit-tested
bounded idle-wait primitive (extracted so daemon.js's restart timing
logic is testable without spinning up a real daemon process).

Pure/detection-only in this commit -- no wiring into daemon.js yet.

TASK: lr-e85fec
…(lr-e85fec)

Wires build-update-check.js's detection into a periodic (5min, plus
10s after startup) poll in lib/daemon.js. On a detected mismatch,
broadcasts a diagnostic with actionable:{label, action:
'restart_for_build_update'} -- the same diagnostic channel
lib/memory-shed.js already uses -- edge-triggered so it fires once
per detected staleness, not every poll.

lib/project-sessions.js gains a restart_for_build_update WS message,
gated by the same admin-only check as restart_server/shutdown_server.
Its handler (onRestartForBuildUpdate, lib/daemon.js) waits for
in-flight sessions to finish (waitForIdleThenAct, bounded 60s, same
getActiveLiveCount() signal drain.js uses) before calling the
existing spawnAndRestart() -- reused verbatim, not reimplemented.

No restart fires without that explicit operator click: no
post_merge_step, no timer, no automatic trigger. Detection and
actuation are on two different pollers/handlers precisely so a
detection failure can never accidentally become a restart.

TASK: lr-e85fec
diagnostics.js's actionable hint (panel + toast) previously only ever
rendered an icon+label hint, even when actionable.action names
something the client can actually do. Adds _buildActionableEl(),
shared by both render sites, backed by an explicit
ACTIONABLE_HANDLERS allowlist -- a diagnostic's actionable.action
string is matched against this allowlist before anything is sent
back over the socket, so the backend can offer an action but never
dictate an arbitrary WS command through the diagnostic payload.

Wires the one handler this task needs: restart_for_build_update ->
sendWs({type: 'restart_for_build_update'}).

Toast auto-dismiss (6s) is suppressed when an action button is
present -- an operator deciding whether to restart must not have the
offer disappear before they've read it.

TASK: lr-e85fec
Records the design decision (session-preserving hot-reload
investigated and rejected as infeasible -- names the specific
reasons: live child processes/PTYs/sockets in-heap, no Node
require() unload primitive, lr-0287 Tier 2 checkpoint/restore not
built) and the shipped shape (detector + attached actuator, gated on
an explicit operator click, reusing existing spawnAndRestart/drain
signal machinery).

TASK: lr-e85fec
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean (0 findings)

Constraint audit: NO automatic restart path detected. Detection (5-min poll + early 10s check) broadcasts a diagnostic with an actionable button. The button click — gated by admin-only check, identical to restart_server — is the sole entry point to restart. On click, waitForIdleThenAct polls getActiveLiveCount with a 60s timeout (explicit, tested, documented), then restarts. If the operator never clicks, nothing restarts.

Admin gating: restart_for_build_update added to the same admin-only gate as restart_server/shutdown_server (lib/project-sessions.js:335-336, same role check at 1876-1878).

Drain semantics: 60s wait on in-flight sessions, then forced restart at timeout. Documented and unit-tested; intent is clear — avoid dropping a session the operator did not know was live, but do not deadlock.

Detection soundness: Direct SHA comparison (lib/build-update-check.js lines 146-154), not string-matching on error messages. Polls every 5 minutes plus once 10s after startup.

Existing warning unchanged: lr-22e8 journald WARN + health.stale still fires independently; this adds an offered affordance on top of it, per architecture.md:72.

Brand rules: All user-visible strings use correct terminology: "npm install -g @clagentic/console" and "Restart to apply update" button label follow the clagentic-console / Clagentic: Console convention.

Behavior verification: Tests (build-update-check-lr-e85fec.test.js) confirm detection logic, drain timing, and idempotency. No test for button UI itself (integration-level), but core logic is sound.

Reviewed against absolute operator directive: NO CREW AGENT RESTARTS THE OPERATOR SERVICES. This diff honors that constraint. Operator decision is preserved; no path bypasses it.

{"reviewer": "peaches", "review_status": "clean", "head_sha": "90dd673aefa148a7ad5d929f2fc90a342a68b104", "pr_number": 420}

@clagentic-security

Copy link
Copy Markdown

BOBBIE security audit of PR #420 (clagentic/clagentic-console), head 90dd673, task lr-e85fec.

Scope: a6e59f2..90dd673 (4 commits, 7 files, matches stated PR scope exactly).

  1. PRIVILEGE BOUNDARY. lib/project-sessions.js:1876 adds msg.type==="restart_for_build_update" to the SAME admin-only gate that already covers restart_server/shutdown_server (lines 1872-1887), checking _wsUser.role !== "admin" before any handler executes. Read the full handler block 1830-2020 sequentially: no separate or weaker path exists.

  2. CSRF/cross-origin. The message only reaches the gated handler after the existing connection-level auth (ws._clagenticUser), unmodified here. Client-side, diagnostics.js:35-37 adds an ACTIONABLE_HANDLERS allowlist so a server-supplied actionable.action string is never forwarded verbatim as a WS message type.

  3. DoS shape. onRestartForBuildUpdate delegates to waitForIdleThenAct (build-update-check.js:96-124), which sets an acted flag so a stray tick cannot double-fire; wait bounded at DEFAULT_RESTART_WAIT_MS=60000ms. Repeated triggers require an authenticated admin -- same exposure class as the pre-existing restart_server action, not a new unauthenticated vector.

  4. THE POLLER. daemon.js: _buildShaPath = path.join(__dirname, "build-sha.json") is a fixed, non-attacker-influenceable path. readInstalledBuildSha (build-update-check.js:33-42) wraps read+JSON.parse in try/catch, returns null on missing/malformed/non-string-sha content. _pollBuildUpdate itself is wrapped in try/catch, logs non-fatally. No unhandled-throw path to a daemon crash from malformed build-sha.json.

  5. INJECTION. Traced the SHA end to end: it appears only in console.log/console.error server-side; the broadcast message field is a fixed string with no SHA interpolated. diagnostics.js renders entry.message/entry.actionable.label via .textContent only -- no .innerHTML use of any server-supplied string in this diff. No XSS surface introduced.

  6. OPERATOR CONSTRAINT. No changes to package.json or .clagentic/loadout/config.yaml (diffs empty for both paths). The only restart trigger is onRestartForBuildUpdate, invoked solely from the restart_for_build_update WS handler, invoked solely from the client button click listener. The setInterval/setTimeout in daemon.js only run the read-only detector, never the actuator.

  7. TEST FILE. test/build-update-check-lr-e85fec.test.js uses an injected fake clock and asserts on actual return values/call counts (e.g. onIdle fires exactly once regardless of pending ticks; stale=false-on-unknown-signal cases). No tome #845 variant observed -- outcome assertions, not existence/no-throw checks.

Scanners: gitleaks clean on a6e59f2..90dd673. semgrep --config auto found 11 findings in lib/daemon.js and lib/project-sessions.js, all at lines outside this PR diff hunks (pre-existing code). osv-scanner: no package.json/package-lock.json changes in this diff; existing lockfile advisories are pre-existing and unrelated to this PR scope.

No findings.

{"reviewer": "bobbie", "review_status": "clean", "head_sha": "90dd673aefa148a7ad5d929f2fc90a342a68b104", "pr_number": 420}

@clagentic-merger
clagentic-merger Bot merged commit 8302c71 into main Sep 7, 2026
4 checks passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.2.0

Field Value
Gated HEAD SHA 90dd673aefa148a7ad5d929f2fc90a342a68b104
Merged SHA 90dd673aefa148a7ad5d929f2fc90a342a68b104
Reviews clagentic-reviewer[bot], clagentic-security[bot]
CI status no-runner-by-design (0 commit-status entries at HEAD)
task_id lr-e85fec

@clagentic-merger
clagentic-merger Bot deleted the feat/lr-e85fec-daemon-build-adoption branch September 7, 2026 22:00
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.

0 participants