Skip to content

Guard the self-update pipeline against shipping broken Python - #34

Merged
elitecoder merged 8 commits into
mainfrom
pulse-syntax-gate
Sep 28, 2026
Merged

elitecoder merged 8 commits into
mainfrom
pulse-syntax-gate

Conversation

@elitecoder

@elitecoder elitecoder commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

What

This PR stops code that won't parse, such as leftover conflict markers or a syntax error, from silently stopping the pulse. It covers code arriving in a pulled commit and code sitting in the working copy. Errors that only show up when the code runs, like a renamed function or a missing package, are out of scope. They still fail with only the launchd error log as a trace.

What actually happened in July 2026: conflict markers were left in the working copy of bin/pulse.py, and the pulse failed with a SyntaxError on every tick for months. The only trace was assistant-pulse.launchd.err, which nobody reads. The markers were never committed. 17f3862:bin/pulse.py compiles, and git log --all -G'^<<<<<<<' finds nothing. The original brief blamed a pulled merge commit, which was wrong. This PR guards both paths anyway.

  • Pre-pull syntax gate (bin/self_update.py). It runs after the fetch and the "ahead" and "dirty" checks, and before any stash or merge.
    • It reads each runtime file the fetched commits add or change straight from git. It scans each one for conflict markers, and it parses the Python files.
    • Runtime files are those under bin/, src/, hooks/, install/, prompts/, skills/, launchagents/, config/, docs/, and slack-reactor/, plus the installers. Symlinks and submodules are skipped.
    • If a file fails, the update is refused: nothing is stashed, merged, or reset. A self-update-syntax-fail ledger entry names the file and says to pull by hand if the refusal is wrong.
    • A refused commit is skipped quietly until the remote moves, with a reminder once a day. The refusal is tied to the Python version that checked it.
    • If git fails during the check, the result is gate-error and isn't remembered.
    • Updates fast-forward to exactly the checked commit with git merge --ff-only <sha>.
  • Launchd pre-flight (bin/run-pulse.py). The pulse LaunchAgent now runs a small Python wrapper.
    • The wrapper parses pulse.py and the modules it imports at startup (assistant, assistant.model_tiers), then replaces itself with the pulse. The pulse keeps the same interpreter and arguments.
    • If a startup file is missing or won't parse, the wrapper records the error in ~/.assistant/pulse-preflight.json. It then exits 0, as the brief asked.
    • It adds an actions-ledger entry, which reaches Slack through the comms daemon, for a new error and then once a day. It also writes one stderr line per skipped run.
    • Once the startup files parse again, it clears the record.
  • Dashboard (bin/render-assistant-page.py). While the recorded failure is newer than the last pulse, a red alert at the top of the page, outside the collapsed services section, shows "Pulse can't start: : SyntaxError: …". The com.assistant.assistant-page agent redraws the page without the pulse. The existing pulse banner is unchanged.

Why the design changed during review:

  • An earlier version reset to the pre-pull commit after a bad pull. git reset --hard could erase operator edits made mid-pull, and it broke the repo's "never reset" rule, so the check now runs before the pull.
  • The wrapper is Python because the changed-code coverage check refuses changed .sh files.

Tests

  • tests/test_self_update.py runs real throwaway git repos. It covers these cases:
    • Refusal in bin/, src/, hooks/, and install/, plus markers in install.sh. HEAD, the reflog, and the working tree stay unchanged.
    • The quiet skip, the daily reminder, and a re-check when the Python version differs.
    • A git error isn't remembered.
    • A dirty tree isn't stashed for a refused update.
    • RST underlines, tests/ files, README.md, binary files, and symlinks don't block a healthy update.
    • A marker inside a string is caught.
    • Diff, read, and fast-forward failures are reported.
  • tests/test_run_pulse_wrapper.py covers these paths:
    • In-process runs with a fake exec check the parse, the recorded error, and the exact exec command.
    • Real subprocess runs cover a good pulse, a broken pulse, a broken or missing startup import, and a broken later module, which must not block the pulse.
    • Tests cover the daily ledger throttle and an unwritable ~/.assistant.
    • Every module-level import in pulse.py must be stdlib or listed in the wrapper's startup files. The plist must run the wrapper.
  • tests/test_renderer_in_process.py covers the top alert for a new failure and for no heartbeat, and checks that an old, corrupt, or missing record renders nothing.
  • tests/test_renderer_brief_tab.py pins the clock in the focus-unpin test. It began failing on main around 2026-09-23, once its fixture alert passed the 4-day freshness window.

Known limits

  • Deployment: new installs get the wrapper right away. Existing machines pick it up after a reboot, a logout, or a manual reload of the pulse LaunchAgent. The gate goes live on the next self-update.
  • Branch switching: checking out a branch older than this PR in the live checkout removes bin/run-pulse.py. The LaunchAgent then can't start the pulse until you switch back, and the banner goes stale.
  • Rollback: reverting this PR through self-update deletes bin/run-pulse.py while the loaded LaunchAgent still points at it. After a revert, reload the agent's definition with launchctl bootout gui/$(id -u)/com.assistant.assistant-pulse and then launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.assistant.assistant-pulse.plist, or reboot.
  • Shared package: the dashboard and the Slack listener import the same assistant package. A broken src/assistant/__init__.py stops the dashboard alert, and stops Slack once the listener restarts. The pre-flight still skips cleanly and logs to stderr.
  • No recovery message: Slack isn't told when the pulse comes back.
  • False positives: a runtime doc or skill that shows real conflict markers at the start of a line would be refused. None do today.
  • Exit code: the wrapper exits 0 on failure, as the brief asked, so launchctl list shows no error. The dashboard banner is where the failure shows.

Operational note (not in this diff)

Unloaded and disabled the dead com.mukuls.assistant-todo-review LaunchAgent. It exited with code 78 because it pointed at ~/.claude/bin/review-todo.py, which doesn't exist and has no history in this repo. It's renamed to .disabled-20260927.

🤖 Generated with Claude Code

elitecoder and others added 8 commits September 27, 2026 00:48
After a git pull, compile the core pulse files and scan them for leftover
merge conflict markers. If either check fails, reset back to the pre-pull
commit, surface a self-update-syntax-fail ledger entry, and skip the update
so the next launchd restart can't crash-loop on broken code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the pulse LaunchAgent through bin/run-pulse.sh, which runs py_compile on
bin/pulse.py before exec'ing it. On failure it logs to the launchd err file and
exits 0, so a broken pulse can't crash-loop launchd into silent throttling.
The plist template now runs the wrapper and passes the arch-resolved python3
as its argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Only flag a bare === line as a conflict marker when an arrow marker is also
  present, so an RST doc underline in a gate file can't false-trip the gate and
  revert a healthy pull.
- Never let the gate's py_compile subprocess raise into the pulse.
- Carry the auto-stash recovery hint into the syntax-fail ledger entry.
- Fix run-pulse.sh and CHANGELOG wording: this LaunchAgent has no KeepAlive, so
  it fires every 300s regardless of exit code; a broken pulse.py can't self-heal
  from inside pulse.py; the wrapper reaches existing machines only after reload.
- Add regression tests: RST underline doesn't trip the gate; wrapper good path
  with no extra args.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ht in Python

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d narrow the pre-flight

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…symlinks, and docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@elitecoder
elitecoder merged commit 0db51d0 into main Sep 28, 2026
1 check passed
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.

1 participant