Skip to content

fix(core): don't mistake a stale daemon PID file for a running daemon (V2-1655) - #235

Open
Nic-dorman wants to merge 7 commits into
mainfrom
nic/v2-1655-ant-node-daemon-infostart-trust-a-stale-pid-file-report
Open

Nic-dorman wants to merge 7 commits into
mainfrom
nic/v2-1655-ant-node-daemon-infostart-trust-a-stale-pid-file-report

Conversation

@Nic-dorman

@Nic-dorman Nic-dorman commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Community report: after an unclean daemon exit, ant node daemon info said the daemon was running (with a console URL that refused connections), status said it wasn't, and start refused to start a new one.

Cause. A daemon that dies without cleaning up (a reboot, an OOM kill, SIGKILL) leaves daemon.pid and daemon.port behind. info and start decided "running" from kill(pid, 0) alone. Once the PID was reused by an unrelated process, they treated it as the daemon. status asks the daemon over HTTP, so it disagreed, and its "Start it with: ant node daemon start" hint went nowhere. Only stop checked that the PID really is the daemon.

Fix.

  • info and start classify the PID file through one helper (recorded_daemon): live and identifiable as the daemon, which is the check stop already applies before signalling.
  • start removes stale PID/port files before spawning. Otherwise its port poll reads the dead daemon's port straight back as the new daemon's.
  • macOS gets a real identity check (executable name via proc_pidpath, as Windows does), where it previously trusted any live PID. This also tightens stop on macOS.
  • info's help text and README no longer say its output is "always JSON". It has been human-readable unless --json is passed since 920f872, which is the convention for every command. The e2e skill already uses info --json.
  • status and stop logic are unchanged.

Every distribution (install.sh, install.ps1, npm, release archives, the Linux .deb) names the binary ant/ant.exe, which is what the identity check requires.

Linear issue

Closes V2-1655

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none
  • Storage: none (same daemon.pid / daemon.port files; stale ones are now removed by start)
  • API: none. No public ant-core items added or changed; DaemonInfo.running is now false for a stale PID file, which is what it documents.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • Unit tests (cargo test -p ant-core --lib node::daemon::client): 12/12 pass on Windows and Linux. The new tests use the test process's own PID as the "reused" PID. The two tests for the reported case (info_with_a_reused_pid_is_not_running, start_clears_files_left_by_a_dead_daemon) fail with the old PID-only check restored, and pass with the fix.

  • Lint: cargo clippy --all-targets --all-features -- -D warnings clean on Linux; cargo fmt --check clean.

  • Reporter's scenario on Linux, real binaries, isolated XDG_DATA_HOME: start a daemon, kill -9 it, write a live unrelated PID into daemon.pid.

    origin/main e47ea7a this branch
    info on a genuine daemon running: true running: true
    info after kill -9 + reused PID running: true running: false
    status running: false running: false
    start already_running: true, pid = reused PID new daemon, new port
    info / status afterwards still disagree both running, same PID
  • macOS is not run locally; the proc_pidpath path is compiled and tested by CI's macos-latest jobs.

  • The e2e skill gains step 5.5, "Stale PID file recovery", which covers the same scenario.

  • Prod compat smoke (2026-10-23), re-run at the rebased head da95adb5 against main ccd4106d. Run on Linux (dev2, stable 1.99.1) with --locked release builds and isolated XDG_DATA_HOME/XDG_CONFIG_HOME.

    • The scenario in the table reproduces on main and is fixed here:
      • On main, after kill -9 and a reused PID, info reports the decoy sleep PID as the daemon. start prints already running (PID <decoy>) and status says not running.
      • On this branch, info says not running and start launches a new daemon. info and status then agree on its PID, and stop exits cleanly.
    • stop on main already catches the reuse: PID … is alive but does not appear to be the ant daemon (possible PID reuse). Stale PID file removed. This PR gives info and start the same check.
    • Mainnet: ant file download of a public canary file (3cba93c8…, 5,277,908 B) with this build succeeded in 14.6 s, and the byte count matches.

New dependency

none (libc is already a dependency; proc_pidpath is from it)

ADR

n/a

Mitigation / rollback

Revert the commit. If a renamed ant binary fails the identity check, the impact is limited to info/start seeing its daemon as stale; stop has always refused such a daemon.

🤖 Generated with Claude Code

Nic-dorman and others added 2 commits October 23, 2026 19:52
… (V2-1655)

A daemon that dies without cleaning up (a reboot, an OOM kill, SIGKILL)
leaves daemon.pid and daemon.port behind. Once the PID is reused by an
unrelated process, `daemon info` and `daemon start` saw a live PID and
treated it as the daemon: `info` reported running while `status` (which
asks the daemon over HTTP) said not running, and `start` refused to start
a new daemon, so the hint `status` prints led nowhere.

`info` and `start` now classify the PID file through one helper that also
requires the process to be identifiable as the daemon, the check `stop`
already applied before signalling it. `start` removes stale files before
spawning; otherwise its port poll would read the dead daemon's port
straight back. macOS now checks the executable name via proc_pidpath
instead of trusting any live PID.

The `info` help text and README no longer claim its output is always
JSON: it has been human-readable unless --json is passed since 920f872,
which is the convention for every command.

Adds unit tests for the reused-PID case and an e2e skill step (5.5).

Closes V2-1655

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rustdoc's private_intra_doc_links lint fails the Documentation job under
-D warnings. Describe the check inline instead of linking to
`recorded_daemon`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nic-dorman
Nic-dorman force-pushed the nic/v2-1655-ant-node-daemon-infostart-trust-a-stale-pid-file-report branch from c714134 to da95adb Compare October 23, 2026 18:52

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagree; I confirmed the platform-identity gap below against the frozen head. CI checks were all passing at review time.

  • P2 — ant-core/src/node/daemon/client.rs:496-516 (also Windows :519-545): the macOS and Windows validators accept any live process whose executable basename is ant/ant.exe; unlike the Linux validator, they do not verify daemon-specific arguments. A stale PID reused by another ant CLI process therefore classifies as Live, so info can again report running: true and start can refuse recovery. Please verify that the process is running the daemon command (or use another daemon-specific identity signal) on these platforms, and add a regression test using a same-executable, non-daemon process. The current test only uses the test harness executable, so it does not cover this case.

Coverage: 2/2 seats reviewed. sol-1 returned CHANGES_REQUIRED; glm-1 returned APPROVE but noted the executable-name limitation. The process-identity issue is reproducible from the platform checks in the diff; it blocks approval.

…d Windows too

Review of #235: the macOS and Windows checks accepted any live process whose
executable is named `ant`. A stale PID reused by another `ant` command, such as
a long `ant file upload`, was still reported as the running daemon, and `start`
still refused to start.

Each platform now requires the process to be `ant` running `daemon run`, the
arguments `daemon start` passes to the detached child. macOS and Windows read
the arguments through `sysinfo`, which ant-core already uses for node
adoption. The executable-name checks stay as they were: they work for another
user's process and for an elevated one. Where the arguments can't be read,
`sysinfo` reports none, and the name check alone decides. Linux keeps its
/proc reading but uses the same rule, which is stricter than the previous
"any `daemon` argument".

New tests copy the test binary as `ant` and run it as `node daemon run`
(accepted), as `file upload` (rejected), and as `node daemon status` behind a
reused PID (`info` reports it not running).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). CI checks pass on the reviewed head. sol-1 returned CHANGES_REQUIRED; glm-1 returned UNCERTAIN. I verified the findings below against the frozen source.

  • P2 — ant-core/src/node/daemon/client.rs:498-500: runs_the_daemon accepts any adjacent daemon run pair anywhere in argv, not specifically the CLI command path. For example, an ant file upload invocation with arguments daemon run passes this check, so a reused PID can still be classified as the daemon and start can refuse recovery. Parse/validate the actual node daemon run command after global options, and add a negative test for non-daemon arguments containing that pair.

  • P2 — ant-core/src/node/daemon/client.rs:531-532: an empty command list is treated as proof of daemon run. The surrounding comment says this is how sysinfo reports unreadable arguments; the executable-name check still succeeds for another user’s ant process. A reused PID held by such a process is therefore reported as live, contrary to the changelog’s cross-platform claim. Represent unreadable identity separately and handle it without claiming the process is confirmed as the daemon; add a test for the unreadable-arguments case.

  • P2 — ant-core/src/node/daemon/client.rs:672-675: stale PID/port deletion errors are ignored, then start proceeds. If the stale port file remains (for example, deletion is denied), the readiness poll at lines 130-136 can immediately return that old port before the new daemon writes its port. Propagate or otherwise handle cleanup failure, and test that startup cannot report a stale port when cleanup fails.

Local targeted Rust tests did not return before the 420-second tool timeout; no local test pass is claimed. The PR’s GitHub checks are green. Coverage: 2/2 seats; no agreement is claimed.

…d fail on stale-file cleanup errors

Second review of #235:
- `daemon run` anywhere in the arguments was accepted, so `ant file upload
  daemon run` holding a reused PID passed. Only `node daemon run` straight
  after the executable, as `start` spawns it, now confirms the daemon.
- Unreadable arguments fell back to the executable name. They are now
  "unconfirmed", like any other `ant` command line, and such a process counts
  as the daemon only if the daemon's API on the recorded port reports its
  PID. That also keeps a foreground daemon started behind global options
  recognised, without copying the CLI's option list into ant-core.
- Errors removing the stale PID and port files were ignored, so the port poll
  could read the dead daemon's port back. `start` now fails instead.

`start` also spawns the daemon only from the `ant` binary, and never from a
unit-test build. Any other harness would read `node daemon run` as test-name
filters and re-run the tests that spawned it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nic-dorman

Copy link
Copy Markdown
Member Author

Addressed in 01d5640:

  1. Command path: now only node daemon run straight after the executable confirms the daemon from its arguments. That's exactly what start spawns through daemon_run_args.
    • daemon run anywhere else no longer counts, as in ant file upload daemon run.
    • Rather than re-parse the CLI's global options in ant-core, any other ant command line is treated as "unconfirmed" and settled by the daemon's own API (see 2). That also keeps a daemon started in the foreground behind global options recognised.
    • Tests: only_node_daemon_run_after_the_executable_is_the_daemon_command, and another_ant_command_holding_the_pid_is_not_the_daemon, which uses a live ant-named process running file upload daemon run.
  2. Unreadable arguments: these are now a separate Unconfirmed state and never taken as proof.
    • An unconfirmed ant process counts as the daemon only if GET /api/v1/status on the recorded port reports that PID.
    • The check is a blocking local request with 1 s timeouts, made only on this path.
    • Tests: unreadable_arguments_never_confirm_the_daemon, the_daemon_api_must_report_the_same_pid, only_a_200_with_the_pid_confirms_the_daemon and an_unconfirmed_ant_process_is_the_daemon_only_if_its_api_says_so.
  3. Cleanup errors: stale-file removal now fails on anything except "not found", and start stops before spawning. Test: a_stale_file_that_cannot_be_removed_is_an_error.

Also: start now spawns only from the ant binary, and never from a unit-test build. A test harness would read node daemon run as test filters.

Verified:

  • Linux (stable 1.99.1): daemon client module 22/22, 50 repeat runs with no failures, fmt, and clippy --all-targets --all-features -D warnings.
  • Windows 11: 22/22 and clippy.
  • macOS: this push's CI. All 22 daemon client tests ran and passed in Unit Tests (macos-latest).

CI on 01d5640: everything passes apart from Security Audit. Merkle E2E (ubuntu-latest) first failed on test_merkle_payment_across_batch_boundary ("Got 14 merkle candidates, need 16", peers unreachable in the 35-node local testnet), then passed on re-run. That test doesn't touch the daemon code.

Security Audit: RUSTSEC-2026-0387 (crypto-bigint 0.5.5, via alloy/k256 0.13) is already on main and has no upstream fix yet; it's tracked in V2-1743. This PR doesn't touch Cargo.lock.

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — re-review of 01d5640. Coverage: 2/2 seats (sol-1 gpt-6.1-sol xhigh: CHANGES_REQUIRED; glm-1 z-ai/glm-5.3: APPROVE). The seats disagree. I confirmed the remaining identity/API failure paths below against the source. The three prior findings are addressed: exact command matching, stale-file cleanup errors, and stale-port removal.

  • P2 — ant-core/src/node/daemon/client.rs:579-584, 746-767 (also stop, lines 66-72): the API fallback returns only bool; a failed connection, timeout, malformed response, and a responding server with another PID all become false. recorded_daemon then marks the still-live PID Stale, and start deletes both files; stop also removes them when confirmation fails. For an actual daemon whose arguments are unconfirmed, a transient API timeout/refusal can therefore orphan the live daemon’s discovery files and allow a second start. Keep “not confirmed” distinct from “confirmed not daemon”; on an inconclusive probe, preserve files and return an actionable error rather than deleting them or signalling the process. Add tests for a live unconfirmed process with an unavailable/timed-out API.

  • P2 — ant-core/src/node/daemon/client.rs:592-605: the fallback always connects to IPv4 127.0.0.1, but DaemonConfig.listen_addr accepts IPv6 addresses. A supported foreground invocation such as ant --json node daemon run --listen-addr ::1 is classified Unconfirmed because --json precedes the subcommand; the fallback then probes the wrong address, classifies the live daemon as stale, and start may remove its files. Use the configured listener address (including the appropriate address family) and add a regression test for this invocation.

  • P2 — ant-core/src/node/daemon/client.rs:543-546 (Windows): is_ant_executable_name uses case-sensitive matching, and start applies it to current_exe(). Windows executable paths are case-insensitive, so the same CLI installed/invoked as ANT.EXE or Ant.exe is rejected before daemon startup. Compare case-insensitively on Windows and test mixed/upper-case executable names.

The PR’s Security Audit check is still failing on RUSTSEC-2026-0387 (crypto-bigint 0.5.5). Cargo.lock is unchanged from the base, so I treat this as an inherited check failure, not a new code finding; it remains a separate approval gate. All other listed checks pass.

…API on IPv6 too, and match `ANT.EXE`

Third review of #235:
- An unanswered daemon API counted as "not the daemon", so a live daemon
  whose API was briefly slow could lose its files to `start` or `stop`, and
  a second daemon could start. Confirmation now has three outcomes. Only an
  API answering with another PID rules the process out. With no answer,
  `start` and `stop` return an actionable error and remove or signal
  nothing, and `info` reports it as not running.
- The API was only looked for on 127.0.0.1. It is now tried on the
  configured listen address (unspecified mapped to the loopback of its
  family), then on 127.0.0.1 and ::1.
- On Windows the `ant` name check was case-sensitive, so `ANT.EXE` was
  rejected. It is now case-insensitive there, for `start` and for process
  identification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nic-dorman

Copy link
Copy Markdown
Member Author

Addressed in 7695a70:

  1. Inconclusive API probe: confirmation now has three outcomes.
    • Only an API answering with a different PID rules the process out.
    • With no answer (refused, timed out, garbled, or no port file), start and stop return an error naming the PID and the reason, and remove or signal nothing. info reports "not running" and changes nothing.
    • Tests: start_keeps_the_files_of_a_daemon_it_cannot_verify (a live unconfirmed ant process, once with the API unavailable and once with it silent until the timeout), stop_neither_signals_nor_clears_a_daemon_it_cannot_verify, another_daemon_answering_on_the_port_rules_the_process_out, and the_daemon_api_reports_its_pid_or_why_it_did_not.
  2. IPv6: the API is looked for on the configured listen address first, then on 127.0.0.1 and ::1. An unspecified address is mapped to the loopback of its family. A daemon on ::1 is found whatever the calling command's config says.
    • Tests: the_daemon_api_is_found_on_ipv6_too (a fake API on [::1], with the config set to ::1 and to the default) and the_api_is_looked_for_on_the_listen_address_then_both_loopbacks.
  3. Windows case: the ant name check is now case-insensitive on Windows, in start and in process identification.
    • Tests: only_the_ant_binary_is_an_ant_executable (ANT.EXE and Ant.exe accepted on Windows only) and an_upper_or_mixed_case_ant_exe_is_ant_on_windows (live helpers with those names).

Verified:

  • Linux (stable 1.99.1, with IPv6 loopback): 27/27, then 50 repeat runs with no failures; fmt; clippy --all-targets --all-features -D warnings.
  • Windows 11: 28/28 and clippy.
  • macOS: this push's CI. Unit Tests (macos-latest) passed, including all 27 node::daemon::client tests (the_daemon_api_is_found_on_ipv6_too among them; the one ignored test is the helper-process body).

CI on 7695a70: everything else is green. E2E Tests (macos-latest) failed on the known test_estimate_all_stored_partial_sample_is_incomplete flake (InsufficientPeers: the close-group lookup found 6 peers and needed 7). That test doesn't touch the daemon code, and the job is being re-run.

Security Audit: still fails only on RUSTSEC-2026-0387, inherited from main (V2-1743).

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — re-review of 7695a7006a10c66e11a058b96b4515c0062e0e0c. The earlier three findings are fixed: daemon argv matching, the IPv6 address fallback, and Windows case-insensitive executable matching. I found three remaining P2 issues:

  • ant-core/src/node/daemon/client.rs:717-730 — macOS executable lookup errors are treated as proof that the process is not ant. proc_pidpath returning an error or zero returns false; process_identity then yields Other, so start can delete the PID/port files and stop can clear them without stopping a live daemon. Preserve an unknown result when executable inspection fails; use API confirmation and do not delete/signal when that is inconclusive. Add a regression test for lookup failure.

  • :643-648 and :615-624 — a different daemon answering on the first address prevents the IPv6 fallback. daemon_api_pid returns the first valid PID response, even when it does not match the recorded PID. confirm_daemon then treats that mismatch as proof the recorded process is not the daemon. If the recorded daemon listens on ::1 and another daemon answers on 127.0.0.1 at the same port, the IPv4 answer causes destructive cleanup without probing IPv6. Try all candidate addresses for the recorded PID; a different PID at one address must not rule out a match at another. Add a dual-stack, same-port test with different PIDs.

  • :679-695 — the one-second read timeout is not an overall response deadline. read_to_end can keep receiving a slow trickle, with each read resetting the timeout, until the 64 KiB cap is reached. A local listener on a reused port can therefore hang info, start, or stop for a long time. Enforce one end-to-end deadline across the request and response, and test a trickling response.

The review panel completed 2/2 seats and disagreed (sol-1: CHANGES_REQUIRED; glm-1: APPROVE). I verified the three findings against the source and resolve that dissent in favor of changes. This is a source review; I did not independently run Rust tests. GitHub reports the macOS E2E check still pending and Security Audit failing on the reported inherited advisory in unchanged crypto-bigint 0.5.5; those are separate from the code findings.

…unknown, and bound the status query

- macOS and Windows: an executable that can't be looked up is unknown, not
  "not ant", so the process goes to the API check instead of losing its files.
- The API check tries every candidate address until one reports the
  recorded PID. Another daemon answering on the same port is no longer
  proof that the recorded process isn't ours; it is inconclusive.
- Each status query has one 1 s deadline across connect, request and
  response, so a trickling listener can't hold `info`, `start` or `stop`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nic-dorman

Copy link
Copy Markdown
Member Author

Addressed in 7be1709:

  1. Executable lookup failure: on macOS and Windows, executable_is_ant now returns None when proc_pidpath, OpenProcess or QueryFullProcessImageNameW fails. An unknown executable is Unconfirmed, never Other. Only an executable known not to be ant still rules a process out.
    • So it goes to the API check. If that doesn't report the PID, start and stop error, and nothing is removed or signalled.
    • A Windows PID reused by a protected process now gets that "can't tell" error rather than an automatic cleanup.
    • Tests:
      • an_executable_that_cannot_be_looked_up_is_never_ruled_out (pure, every platform);
      • a_failed_executable_lookup_leaves_the_daemon_unconfirmed (macOS and Windows): looks up a PID no process holds; expects None, Unconfirmed, and Inconclusive from confirm_daemon.
  2. A different daemon on the first address: every candidate address is now tried until one reports the recorded PID.
    • A different PID is no longer proof the process isn't ours, at any address. Ours could still be on an address we didn't try. So an answer for another PID is inconclusive, and the error lists what each address said.
    • Tests:
      • a_daemon_on_ipv6_is_found_behind_another_on_ipv4_at_the_same_port: PID 7 on 127.0.0.1:p, which is asked first; PID 42 on [::1]:p; expects a confirmation.
      • another_daemon_answering_on_the_port_does_not_rule_the_process_out: a live unconfirmed ant process plus another daemon's API; start errors and keeps both files.
      • the_daemon_api_reports_its_pid_or_why_it_did_not now covers the other-PID answer too.
  3. Read timeout vs deadline: each status query now has one 1 s deadline covering connect, request and response.
    • Every read's timeout is what is left of it, and running out is reported as the deadline.
    • Answers over 64 KiB are rejected.
    • The whole check is bounded by about 1 s per address, so about 3 s at most.
    • Test: a_trickled_answer_is_cut_off_at_the_deadline. A listener sends a byte every 100 ms. The query must fail with the deadline error in under 3 s. With only a per-read timeout no read waits more than 100 ms, so it would run until the listener stops after about 10 s.

Verified:

  • Linux (stable, with IPv6 loopback): 30/30, then 50 repeat runs with no failures; fmt; clippy --all-targets --all-features -D warnings. The two IPv6 tests ran rather than skipping.
  • Windows 11: 32/32 (including the macOS/Windows lookup test and the Windows case test) and clippy. The IPv6 and deadline tests ran rather than skipping.
  • macOS (this push's CI): 31/31 node::daemon::client tests, including all five new ones.

CI on 7be1709: every job passed apart from the Security Audit, and no known flake came up.

Security Audit: still fails only on RUSTSEC-2026-0387, inherited from main (V2-1743).

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — reviewed head 7be1709756ffe324e2e617a29e24d7985f14ff59. The three findings from my prior review are fixed in this head: executable lookup errors remain unconfirmed, API probing continues until the recorded PID is found, and the status request has an end-to-end deadline.

Coverage: 2/2 review seats (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). Both returned APPROVE; glm-1 completed on one narrowed retry after its initial request timed out. GitHub unit tests, E2E tests, builds, clippy and formatting pass.

Merge condition: Do not merge until Security Audit passes on the commit to be merged. It is currently failure on this head, with RUSTSEC-2026-0387 (crypto-bigint 0.5.5); Cargo.toml and Cargo.lock are unchanged in this PR. This approval is not a waiver of that gate and is not merge authorization.

#238 rotated the old [Unreleased] into the 0.3.7 to 0.3.12 sections, so
this PR's entry moves to the new, empty [Unreleased], under the same
heading it had before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants