Skip to content

feat(ag2-sparrow): Hatch self-upgrade to the served target_version, with an offline rollback - #5676

Merged
sonichi merged 28 commits into
mainfrom
feat/sparrow-self-upgrade
Oct 25, 2026
Merged

sonichi merged 28 commits into
mainfrom
feat/sparrow-self-upgrade

Conversation

@sonichi

@sonichi sonichi commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Priority

Level: medium
Reason: lets a Hatch install follow the backend's target version with an offline rollback. It stays dormant until the backend has a production transport for the served setup.

Closes

N/A (proposal, no issue).

Base

main. #5665 (served wording, the setup cache and the heartbeat's package_version) has merged as 0776ebb58; this PR was rebased onto main in round 4 and main was merged in again at 67b3b908. Nothing is stacked on another PR any more.

Summary

The self-upgrade moved out of #5665 into this PR. The fixes for both review rounds on #5665 are in.

  • Decision. The keepawake run decides and logs the decision on its line, AUTO_UPGRADE=0 included: upgrade=off, current, below-installed, min-above-target, no-hashes, unsupported-install, running, <v>:started, <v>:attempted or <v>:crashed.
    • It makes at most one attempt per target version, and only to a higher version.
    • It never upgrades when min_version is above the target, without a valid target_sha256 map, or for an editable or URL install (direct_url.json).
  • Crash recovery first. Under the lock, before any other decision, an attempt still started after 30 minutes is marked crashed, even if its target is now installed. If it left another version installed, the snapshot is restored.
  • Lock. The detached worker holds a non-blocking flock on STATE_DIR/upgrade.lock; a second run logs "upgrade already running" and skips. Keepawake holds the same lock across its listener probe and restart, so neither can interleave with an upgrade.
  • Re-read under the lock. The worker reads the installed version again and skips a queued target that is not above it.
  • Order: fetch, snapshot, stop, install, start, verify (current shape; the round sections below give the history).
    • Fetch: pip download --require-hashes of the pinned wheel; its own RECORD and entry points name every path the target installs.
    • Snapshot: hash and copy the package's files (old RECORD, ag2_sparrow* entries, and whatever sits at a target path) to STATE_DIR/rollback/<version>/, with that inventory.
    • Stop: through the manager that owns Sparrow; a lock holder is signalled only through a pidfd after its argv and start time are proven, and never where no pidfd exists.
    • Install: python -m pip install --no-index --find-links <fetched> --no-deps --only-binary=:all: --require-hashes -r <req>, with every sha256 in the map in the requirements file (pip accepts --hash only there). Then pip check must run and report no new problem naming ag2-sparrow, and install --no-start is re-run.
    • Start and verify: start Sparrow, then confirm a fresh interpreter imports the target and Sparrow reconnects.
  • install --no-start never starts the service. It enables the unit without --now and does not restart a changed unit. So the re-run install cannot bring the target up before the upgrade's own start and checks.
  • pip check must run. One that exits with neither 0 nor 1, or times out, is a failure: before the stop, nothing is changed; after the install, the upgrade rolls back.
  • In-flight replies. A reply posted while Sparrow is stopped stays in the results dir and its outbox, which the upgrade never touches, and is delivered after the start.
  • Rollback. It restores the snapshot with no index and no network, re-runs install, starts Sparrow and checks the outcome.
    • The outcome is rolled-back only when the previous version is installed again and Sparrow reconnects.
    • Otherwise it is restore-failed, recording the problems and the version actually installed. Causes include a stop, restore, install or start failure, no reconnect, or the wrong version.
    • Either outcome sets the health reason upgrade-failed, which verify now names with a hint.
  • Hook change. A REQUIRED hook: line from the re-run install is not applied. The hook wakes the agent once with it.

This needs a live post-restart witness. It can only come once the backend has a production transport for agent.setup.steps. Until then setup_fetch returns None, so the keepawake run decides nothing and never upgrades. It is not claimed here.

Files to read first

adapters/hatch/self_upgrade.py, then the keepawake and _keep_listener hunks in keepalive.py, the install_service may_start hunk in cli.py, and the notice branch in ag2-sparrow-hatch.sh.

Docs

The package README's "Served wording and self-upgrade" section describes it. docs/ is N/A for the same reason as #5665.

Before / after evidence

At #5665's head, no self-upgrade exists. The review repros now each reproduce as a test that passes at this head and fails when its fix is reverted:

  • install --no-start started the unit (enable --now)
  • a failed restore was recorded as rolled-back
  • pip check exit 127 or a timeout passed
  • a delayed worker downgraded from 0.7.0 to 0.6.0
  • keepawake restarted Sparrow while an upgrade held the lock
  • a crash after install was reported as current
  • verify raised KeyError: 'upgrade-failed'

Test plan

tests/test_hatch_served_setup.py covers 100% of self_upgrade.py and runtime_text.py. pip and every subprocess are faked against a fake site-packages holding a RECORD-listed ag2-sparrow, so there is no network and no real pip. Tests for this round:

  • Service.test_no_start_never_starts_or_restarts_the_service, Service.test_install_hands_no_start_to_the_service_step
  • test_a_failed_restore_is_recorded_as_restore_failed_with_what_is_installed, test_each_failed_recovery_step_is_restore_failed_never_rolled_back (stop, install, start, reconnect, wrong version)
  • test_a_pip_check_that_cannot_run_is_a_failure (exit 127, exit 2, timeout; before and after the install)
  • test_a_queued_target_at_or_below_what_is_installed_now_is_skipped, test_keepawake_holds_the_upgrade_lock_across_its_probe_and_restart
  • test_an_interrupted_upgrade_is_settled_before_the_current_check, test_a_crash_with_nothing_changed_is_marked_without_a_restore
  • Keepalive.test_a_failed_upgrade_is_abnormal_and_verify_names_it

These tests from the earlier round still pass:

  • snapshot, stop, install, start, verify order
  • a queued reply delivered after the start
  • two overlapping upgrades make one pip call
  • rollback with the index unreachable
  • editable installs skipped
  • --no-deps, --only-binary=:all: and --require-hashes, with every hash passed
  • the pip check filter
  • logged decisions
  • AUTO_UPGRADE=0
  • one attempt per version
  • no hashes means no upgrade, no downgrade, and the ordering guard
  • PEP 440 sorting

Mutation check for this round: 19 single-line mutations of the new fixes, and every one fails its test. The earlier round's mutations were rerun: 23 still apply and every one fails its test; 4 target lines this round rewrote, and the rewritten lines were mutation-checked again.

Run locally on python3 3.14:

  • test_hatch_served_setup.py: 69 OK
  • test_hatch_adapter.py: 174 OK (skipped=2)
  • tests/gateway-disposal-delegation.test.py: OK
  • tools/test_no_drift.py: PASS
  • scripts/review-checks.sh: PASS

Hardcoded paths

None in production code. The test fixtures use placeholder paths.

Rebased onto main (2026-10-24)

Rebased the one self-upgrade commit from d2a0c10d onto the current stack base feat/sparrow-served-wording @ cad22997 (#5665's tip, which already contains all of origin/main; 76 main commits and #5665's later review rounds come in through it). Old head 146757f4, new head 0216c111. The stack is unchanged: base stays #5665, merge order #5665 then this.

Conflicts and how each was resolved:

Semantic follow-ups the auto-merge did not catch:

  • self_upgrade.stop_sparrow used keepalive._service_managed() and _cli._systemd_run([...]), the first gone since feat(credentials): one owner for gateway credential precedence and masking (prerequisite for #5665) #5698 reached the base (_systemd_run is still re-exported by cli.py; the switch to _systemctl matches restart_listener either way). It now follows restart_listener: _unit_owner(), fail-closed on UNIT_UNREACHABLE (not stopped, says why), _systemctl(("--user", "stop", ...)) for our unit. The test covers the unreachable case.
  • feat(ag2-sparrow): queue AG2 Space inbox tasks into one persistent Claude Code cloud session #5660 (via the base) stops an active service before writing a changed unit. With --no-start that service is now left stopped with not started (--no-start) (keepawake starts it on its next run, as the flag's help says); this PR's "the unit changed but was not restarted (--no-start)" branch remains for the stop-failed path, and the base's "it is restarted after the install instead" line says "it keeps running the old unit (--no-start)" there instead. test_no_start_never_starts_or_restarts_the_service asserts both paths; nothing was dropped as moot.

Run at 0216c111 (python3 3.14): test_hatch_served_setup.py 154 OK; test_hatch_adapter.py 214 OK (skipped=2); scripts/review-checks.sh PASS; git diff --check clean.

Review round 2 (2026-10-24): 0216c111 -> 6cb77fc5 -> 2c138cd7

One commit on 0216c111 (plus 2c138cd7, a rename only: the settler is settle_attempt, since the repository reserves the name reconcile for the delivery contract's (attempt) signature), answering keweichen's three P1s and liususan091219's interrupted-recovery finding. Still a draft: the live post-restart upgrade/delivery witness is owed once the backend serves agent.setup.steps, and is not claimed here.

P1 rollback certified a mixed install. The snapshot is now a hashed inventory of the whole package namespace, taken before anything changes: the top-level names RECORD lists, every ag2_sparrow-*.dist-info whatever its version, and the console scripts (self_upgrade.py:217 namespace, :237 snapshot). restore() (:283) never reads RECORD or _distribution(): it removes every namespace path the snapshot does not list, copies the snapshot in, and requires the namespace to hash equal to it (namespace_drift, :269); a mismatch raises, so _rollback records restore-failed with the diff and never rolled-back. Tests compare the restored namespace to an independently hashed pre-upgrade inventory and prove the target-only files are gone: test_a_partial_install_that_added_target_only_files_is_removed_exactly (nonzero pip after target bytes + a target-only module, old dist-info kept), test_a_partial_install_with_a_half_swapped_dist_info_is_removed_exactly, test_a_stale_attempt_that_left_target_residue_beside_old_metadata_is_restored (Kewei's shape: the version still reads 0.5.4, the stale RECORD cannot name the module; settle_attempt restores on drift, not on the version), and test_a_restore_that_does_not_hash_equal_to_the_snapshot_is_restore_failed.

P1 no proof of quiescence. One contract in keepalive, read by ensure_sparrow as well (keepalive.py:392 resolve_sparrow: owner, the lock holder's pid if still alive whatever its heartbeat, and whether the heartbeat is fresh; :463): quiesce_sparrow (:413) enumerates owner in {ours, other, UNIT_UNREACHABLE, None} x holder in {none, live, stale-heartbeat-but-live}. other and UNIT_UNREACHABLE are refused with nothing signalled, and _decide refuses them before any worker is spawned (self_upgrade.py:400, :403: hand-made-service, unit-unreachable on the keepawake line). For ours, the unit is stopped and its MainPID must be gone within the bounded wait (_wait_gone, :404); a lock holder beside the unit is stopped only when proven Sparrow, else the whole stop is refused; a stale-heartbeat holder that is still alive is SIGTERMed and must be gone; a final re-read of the lock must show no live holder. start_sparrow(cfg, manager) (self_upgrade.py:512) starts only through the manager that was stopped (unit needs the unit still ours, detached needs no unit). A rollback whose stop is unproven changes no file (_rollback, :522). Tests: class Quiesce (hand-made unit, unreachable manager, our unit stopped and proven gone / still running, inactive unit beside an external holder, external holder not proven Sparrow, stale-heartbeat live holder, SIGTERM ignored, holder still in the lock, ensure_sparrow reads the same resolution fresh-only), test_stop_is_keepalive_quiescence_and_start_keeps_the_manager, test_a_rollback_whose_stop_is_unproven_changes_no_file, test_a_hand_made_or_unreachable_unit_is_never_upgraded_unattended.

P1 keepawake restarted before reconciling. keepawake settles a dead attempt under the initial lock before _keep_listener probes or restarts anything (keepalive.py:553, _settle_upgrade :593); that verdict is the line's upgrade= value. test_keepawake_settles_a_dead_upgrade_before_it_restarts_the_listener uses the exact state (stale started attempt, target files in place, listener down) and asserts the event order stop, install, start, version, restart@0.5.4 with the log line listener=down restarted=1 upgrade=0.6.0:rolled-back.

Interrupted recovery (Susan). The attempt record is a journal: _phase (self_upgrade.py:492) writes snapshot, quiesced, installing, installed (:607, :611) and restoring (:528) before each step, under the lock only. settle_attempt (:448) therefore settles an attempt with any phase as soon as the lock is free, and a phase-less one after 30 minutes: nothing installed and no drift is crashed (Sparrow restarted through its manager if the attempt had stopped it); a mutating phase, drift, or restoring is restored, and crashed/rolled-back/restore-failed are written only when settled. test_an_interrupted_restore_resumes_on_the_next_run kills the restore with SystemExit the way the keepawake timeout does, asserts the phase is restoring and the target still installed, and the next run restores the exact inventory. Also test_a_crash_after_the_stop_restarts_sparrow_through_its_manager_only, test_a_dead_attempt_in_a_mutating_phase_is_settled_at_once, test_a_mutating_crash_with_no_snapshot_is_restore_failed_not_rolled_back, test_the_attempt_journal_names_each_phase_and_the_manager.

Mutation check for this round, one test each: removing the dist-info name match, the drift check in settle_attempt, the post-restore hash verification, the other refusal, the reconcile-before-probe order, the unproven-stop abort, and the restoring phase write each fail their test.

Run at 2c138cd7 (python3 3.14): test_hatch_served_setup.py 175 OK; test_hatch_adapter.py 214 OK (skipped=2); tests/delivery-core-enforcement.test.py 15 OK; scripts/review-checks.sh child-only and cumulative PASS; git diff --check clean. CI at 6cb77fc5 was red on two shards: 5/9 was the reconcile name rule (fixed by the rename); 2/9 was tests/start-cli-private-socket-foreign-core.test.py failing in its own tempdir cleanup (Directory not empty), a test that drives src/agent/start-cli.sh with stubs and never reaches this package; this PR changes no src/ file, and the test passes 4/4 locally at this head.

Transport note (non-blocking, for the transport PR). target_sha256 only guards against a tampered index or wheel; target_version and the hashes arrive in the same served payload, so the eventual transport for agent.setup.steps must be authenticated end to end, and its PR should say how.

Review round 3 (2026-10-24): 2c138cd7 -> 2c8aa473 -> 70015e7c

Two commits on 2c138cd7 (the first landed before keweichen's round-3 review arrived, so the rest went into a second; nothing amended). Still a draft; the live post-restart upgrade/delivery witness is still owed and not claimed.

P1 no-unit stop SIGTERMed an unverified pid (Susan, Kewei). quiesce_sparrow's detached branch now requires is_sparrow_pid(holder) before _stop (keepalive.py:449), exactly as the unit branch does for an outside holder: a live pid behind a stale or fresh lock record that is not proven Sparrow is refused with nothing signalled and no manager. Quiesce.test_a_lock_holder_not_proven_sparrow_is_never_signalled_with_no_unit covers both heartbeats with zero signal calls; test_a_live_holder_with_a_stale_heartbeat_is_still_stopped now marks its pid Sparrow explicitly.

P1 rollback scope came from the old RECORD (Kewei; Susan non-blocking). _record_scope (self_upgrade.py:210) records, before any mutation, the full top-level listing of the site dir and of each scripts directory beside the RECORD roots. _owned_tops / _owned_scripts (:227, :240) treat every name absent from that listing, every ag2_sparrow* name and the RECORD roots as the package's; namespace, namespace_drift and restore (:252, :297, :315) all read that scope, so a top-level package or console script the target introduced, prefixed or not, is reported as added and removed, while a foreign package already present is left alone. test_a_target_that_adds_a_top_level_package_and_a_script_is_rolled_back_whole installs a target adding ag2_sparrow_newtop/, sparrow_vendor/, bin/ag2-sparrow-newcmd and bin/sprw-tool beside a pre-existing other_pkg/, and asserts both the independent inventory and the site listing equal the snapshot's after the rollback (full path and stale-attempt path). Removing either listing diff fails it.

P1 failed recovery still launched unverified bytes (Kewei). _rollback (:584) starts Sparrow only after a verified restore; a restore or hash failure records restore-failed with "Sparrow is left stopped" (:602) and _finish writes the hook's wake notice (:539). keepawake settles first and then refuses _keep_listener while recovery_blocked (:546: any attempt in a mutating phase or restore-failed) or a settlement that raised says so (keepalive.py:557-564, _settle_upgrade :603); the line carries restarted=0 reason=upgrade-failed, rc 1. The owner's ag2-sparrow-hatch install (not --no-start) lifts the hold through clear_blocked (self_upgrade.py:556, cli.py:756); the attempt stays recorded, so the target is never retried. Tests: test_a_failed_restore_is_recorded_as_restore_failed_with_what_is_installed (no start after the failed restore), test_a_restore_that_does_not_hash_equal_to_the_snapshot_is_restore_failed (now asserts no start), test_a_failed_restore_holds_keepawake_restarts_until_the_owner_installs, test_keepawake_never_restarts_on_unverified_files_or_a_failed_settlement (dead mutating attempt with no snapshot; settlement raising), and in the adapter suite test_an_install_that_starts_clears_a_failed_upgrades_hold_and_no_start_does_not.

Dead clause (Susan). settle_attempt's separate phase == "restoring" test was unreachable (an interrupted restore still carries outcome: started with a phase set); dropped.

Mutation check, one test each: the no-unit is_sparrow_pid gate, the site-listing diff, the bin-listing diff, the no-start-after-failed-restore branch, the keepawake hold, and the raise-blocks path each fail their test when removed.

Run at 70015e7c (python3 3.14): test_hatch_served_setup.py 179 OK; test_hatch_adapter.py 216 OK (skipped=2); tests/delivery-core-enforcement.test.py 15 OK; scripts/review-checks.sh child-only and cumulative PASS; git diff --check clean.

Still open, non-blocking: the transport that serves target_sha256 must be authenticated end to end (for the transport PR).

Review round 4 (2026-10-24): 70015e7c -> b7b9b950

Rebased onto main (3f38f181f) with git rebase --onto origin/main cad229978: #5665's squash 0776ebb58 has the same tree as cad229978, so only this PR's 5 commits replayed, with no conflicts. Four commits answer keweichen's round-4 review; each test failed on the parent code and passes at the head.

  • Malformed journal fails closed (95a78559b). Only an absent hatch-upgrade.json means nothing is pending; an unreadable one raises JournalError and holds. Parent: a truncated {"attempted": gave (rc, restarts, spawns) = (0, 1, 0); head: (1, 0, 0), reason=upgrade-failed. Test test_an_unreadable_journal_holds_restarts_and_only_an_absent_one_means_none.
  • Process identity = pid + start time (9740c60cb). The workspace lock records proc_start (additive to schema 1); a holder is signalled only when its live start time equals it. Parent: a reused pid was SIGTERMed ((True, 'detached', [('term', 4242)])); head: refused. Test Quiesce.test_a_reused_pid_running_another_sparrow_is_never_signalled.
  • The hold is validated before any start (ca60d2123). Install calls release_hold() before install_service: a restore-failed hold lifts only when every RECORD row hashes equal on disk (RECORD parsed directly, since Distribution.files drops rows whose file is missing) and no unrecorded file sits in an ag2_sparrow* directory. Keepawake, maybe_start and the worker refuse while held; a failed restore never imports the package. Parent: enable --now ran with the phase still restore-failed; spawns 1 for a newly served target; installed_version() imported after the failure. Head: none of these. Tests Service.test_a_failed_recovery_hold_is_lifted_only_by_verified_files_and_before_any_start, test_a_held_recovery_starts_no_upgrade_and_the_worker_refuses_one, test_a_failed_restore_never_imports_the_unverified_package, test_the_installed_files_verify_only_when_every_recorded_hash_matches.
  • Ownership = old RECORD + the target wheel's inventory (b7b9b950b). Parent: a target file nested in a pre-existing foreign directory survived with no drift, and a foreign package and script added after the snapshot were deleted. Head: the former is removed, the latter kept and uncounted; an unknown file in ag2_sparrow* is kept and reported, so the restore is restore-failed. Tests test_a_target_file_nested_in_a_foreign_directory_is_rolled_back, test_a_foreign_addition_after_the_snapshot_is_neither_deleted_nor_counted, test_the_target_inventory_is_read_from_its_wheel_before_anything_changes, test_an_unknown_file_in_the_package_directory_is_kept_and_never_certified. Inventory checked against a real built 0.6.0 wheel (115 paths, all 5 console scripts).

Review round 5 (2026-10-24): 67b3b908 -> c245119a

  • A refusal never rewrites the hold (089dc124a). The worker's recovery-blocked refusal is read-only. Parent: a same-target refusal replaced restore-failed with recovery-blocked, so the next keepawake restarted. Head: the journal is byte-identical and keepawake gives (1, 0) for restore-failed, installing, installed and restoring. Test test_a_same_target_refusal_leaves_the_hold_exactly_as_it_was; also test_an_attempt_record_the_writer_cannot_produce_holds_restarts.
  • The proof is bound to the signal; macOS records carry a start time (c245119af). stop_proven() opens a pidfd, then proves, then signals through it. On macOS proc_start() reads kern.boottime + kinfo_proc.p_starttime and proc_argv() reads KERN_PROCARGS2 through sysctl. Parent: a pid swapped after the proof was signalled (('term', 4242, 'boot:999')); on macOS proc_start was None. Tests Quiesce.test_a_pid_reused_between_the_proof_and_the_signal_is_never_signalled, test_the_holder_is_signalled_through_a_handle_opened_before_its_proof, RealHolder, and the workspace-lock start-time test.

Review round 6 (2026-10-24): c245119a -> 551cc8c6

  • The journal is validated as the writer's state machine (fe704bb13). The file must be what save_state writes (an attempted map, optional failed/installed maps, nothing else), and each attempt a legal (outcome, phase): started with no phase or a working phase (snapshot, quiesced, installing, installed, restoring); a terminal outcome as its own phase; or restore-failed acknowledged. Anything else holds; restore-failed holds whatever its phase unless acknowledged. A worker re-run of an already settled target starts a fresh record, so the writer never journals a working phase over a terminal outcome. Parent: an existing {} read as no hold. Test test_a_journal_state_the_writer_cannot_reach_holds_restarts ({}, restore-failed with no phase / ok / rolled-back / installing, and eight more impossible pairs, plus legal controls).
  • No pidfd means no signal (551cc8c6d). Only a pidfd carries the signal. Where the platform has none (macOS) or pidfd_open fails (EMFILE, EPERM, ...), nothing is signalled and quiesce refuses, so a detached macOS self-upgrade ends stop-failed with Sparrow left running the old version. Guarantee: on Linux with pidfds, the SIGTERM reaches the process whose argv and start time were proven, or none. Limitation: on macOS (and Linux without pidfds) unattended self-upgrade of a detached Sparrow does not stop it, and so never upgrades it, until an identity-bound or cooperative stop exists. Parent: with no pidfd, a pid swapped after the final proof was signalled ((True, [('term', 4242, 'boot:999')])); head: (False, []). Test Quiesce.test_with_no_stable_handle_nothing_is_signalled_and_the_stop_fails (no pidfd, EMFILE, EPERM; detached and beside our unit; the upgrade's stop has no manager) and RealHolder on macOS.

Run at 551cc8c6 on macOS (python3 3.14): test_hatch_served_setup.py 195 OK; test_hatch_adapter.py 218 OK (skipped=2); scripts/review-checks.sh PASS; git diff --check clean.

The live post-restart round trip (upgrade through the live service, one queued delivery surviving the stop, delivered after restart, no repeat) is not claimed here.

Review rounds 7-12 (2026-10-25), rebuilt from the commits and review threads

The body last recorded round 6. These sections were rebuilt from each commit message and the review it answered. The output quoted at the time was not kept, so none is reproduced here.

  • Round 7: review at 551cc8c6 -> 67ab29c8. A failed or installed summary must name an attempt saved with it. Before, {"attempted": {}, "failed": {... restore-failed ...}} read as no hold, so keepawake restarted and release_hold() returned None.
  • Round 8: review at 67ab29c8 -> 92018c0b. A newer attempt keeps a summary only when its writer does. failed survives only a started rerun or one ending in a non-summary outcome; installed survives anything but ok and not-started. Every at must be finite: NaN, inf and out-of-range ints held nothing before.
  • Round 9: review at 92018c0b -> 4a4cd735. Validity no longer rests on wall-clock order. Every time must lie in 0 .. now + CLOCK_SKEW_S, and a summary's own attempt is told from a rerun by outcome alone. Duplicate member names are rejected while parsing. The spawn-failure not-started write moved under the lock, and only over the record maybe_start wrote.
  • Round 10: review at 4a4cd735 -> 440eb9d6. Every field of every record is type-checked as its writer saves it, before any membership test. Any validator exception is a JournalError, so "outcome": [] holds instead of raising TypeError.
  • Round 11: review at 440eb9d6 -> 5761ea48. Attempts need from and a version key; hook only with ok; manager only from quiescence; summaries need every field their writers save. Decoder recursion is a JournalError.
  • Round 12: review at 5761ea48 -> f6287e95. The field checks became a table of the (outcome, phase) variants the writers leave, with the manager each carries. A success needs its installed summary, and a failure summary's outcome must agree with its problems.

Review round 13 (2026-10-25): f6287e95 -> 228cc369 -> ae93e998 -> e1b7e398

Three commits, none amended. The first adds the cross-record rules, the second makes provenance the whole attempt lineage, and the third resolves that lineage as a chain with a named fault.

P1: cross-record recovery history. The table validated each attempt alone. A crashed, rolled-back or restore-failed attempt is saved in the same write as its failed summary, and only a later success drops that summary.

  • Lineage edge. A success now saves installed.after: every attempt the journal held when it was saved, sorted. That is the only "ordered after" evidence. Wall-clock times, key order and version order are never read, and neither is a matching target or version.
  • Resolver. success_lineage(state, terminal) walks from the terminal to the installed success through that edge. It returns the chain it traversed and the fault that breaks it: no success, an equal reference, a cycle, a missing link, a fork (another success outside the lineage), a success outside its lineage, or a lineage not as its writer saves it. When the installed reference fails, it never falls back to another success.
  • Rules. A recovery attempt with no failed summary needs a fault-free chain to the installed success. A held (unacknowledged) restore-failed never lacks its summary, since nothing can succeed while it holds. When failed describes the same attempt (same target, same outcome), its from must equal the attempt's from; an unrelated historical terminal is not forced to match.
  • Named faults. The validator now returns why it rejects a journal (_journal_fault). load_state still raises JournalError for any fault, and for any validator exception.

Tests (test_hatch_served_setup.py, class Upgrade):

  • test_every_journal_the_real_writers_save_is_one_variant: writer -> accepted. The production writers run every ending of a run on 0.6.0: success, each failure step, and each settlement of a dead attempt (before quiescence, quiesced, mutating; with and without a snapshot or drift; restore ok or failed; acknowledged). Each ending runs after each settled history (none; a success; a crash; a rollback; a held then acknowledged restore, with and without a manager; each after a success), with an owner reinstall of the same or another version between. Second-target histories (sequential failures, failure -> success -> rerun, two successes in sequence) are included. Every journal they save is accepted.
  • test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape: accepted -> reachable, and the reverse, over every one-target shape: outcome x phase x manager x hook x summary kind with matched or mismatched from x installed, 21,280 shapes.
  • test_a_journal_one_step_from_a_writers_holds_everywhere: each written shape with its manager flipped, its summary dropped or its from mismatched, where no writer saves the result. Each is held through load_state, recovery_blocked, failed_since, the health row, release_hold, two keepawake ticks, and byte identity.
  • test_recovery_history_is_accepted_only_as_its_writers_leave_it. Positive rows, each located among the production writers' own saves: a historical terminal beside the current matched summary, the managerless restore-failed and acknowledged variants, failure -> success, failure -> success -> direct rerun, and two successes in sequence. Thirteen negative rows, counted in the test, each asserting its traversed chain, its lineage fault and the journal's reason, then the full hold path above.
  • test_an_invalidated_reference_never_falls_through_to_another_success. It takes a writer-saved crash -> success journal, whose chain ["0.6.0", "0.7.0"] has no fault. It drops the edge while the installed record stays present: the resolver still reaches that record and rejects it as a success outside its lineage. It then moves the reference onto a crashed rerun beside the earlier success: ["0.8.0", "0.8.0"], an equal reference, with no fallback to 0.7.0. Both are held everywhere.

Reviewer's two shapes, parent vs HEAD (keepawake tuples are (return_code, generic_restart_count)):

PARENT f6287e957fe12b7a8641ebb0345de62b45980b1d
== bare restore-failed/acknowledged
load_state=accepted
recovery_blocked=None
failed_since(0.5.4)=None
release_hold=None verify_calls=0
keepawake_1=(0, 1)
keepawake_2=(0, 1)
bytes_unchanged=True
== crashed, failed.from contradicts attempt.from
load_state=accepted
recovery_blocked=None
failed_since(0.5.4)=None
release_hold=None verify_calls=0
keepawake_1=(0, 1)
keepawake_2=(0, 1)
bytes_unchanged=True

HEAD e1b7e39840aae48cba9b26406ceb08ed5e9574f1
== bare restore-failed/acknowledged
load_state=JournalError
recovery_blocked=held (cannot be read)
failed_since(0.5.4)=set
release_hold=held verify_calls=0
keepawake_1=(1, 0)
keepawake_2=(1, 0)
bytes_unchanged=True
== crashed, failed.from contradicts attempt.from
load_state=JournalError
recovery_blocked=held (cannot be read)
failed_since(0.5.4)=set
release_hold=held verify_calls=0
keepawake_1=(1, 0)
keepawake_2=(1, 0)
bytes_unchanged=True

Negative rows: parent result -> HEAD result | row | HEAD reason. At the parent, installed.after is stripped, since the parent schema has no such field.

accepted -> rejected | a terminal with neither its summary nor a success | a recovery of 0.6.0 without its summary, and no success
accepted -> rejected | two terminals and no summary | a recovery of 0.6.0 without its summary, and no success
accepted -> rejected | a success that time, key order and version call later, outside the lineage | a recovery of 0.6.0 without its summary, and a success outside its lineage
accepted -> rejected | a success that is the terminal | a recovery of 0.6.0 without its summary, and an equal reference
accepted -> rejected | a success naming itself | an installed summary with a cycle
accepted -> rejected | a success naming a missing attempt | an installed summary with a missing link
accepted -> rejected | a second success outside the lineage | an installed summary with a fork
accepted -> rejected | a lineage listed twice | an installed summary with a lineage not as its writer saves it
accepted -> rejected | the same version with no provenance | a recovery of 0.6.0 without its summary, and a success outside its lineage
accepted -> rejected | a success that crashed on rerun, beside an earlier one | a recovery of 0.8.0 without its summary, and an equal reference
accepted -> rejected | a held restore-failed before the success | a held restore-failed without its summary
accepted -> rejected | a success without what it came after | an installed summary of no success
accepted -> rejected | a matched summary with the wrong from | a failed summary whose from is not its attempt's

Named tests, raw output (temp paths redacted to <worktree>/). Command, run at each SHA with these tests:

python3 packages/ag2-sparrow/tests/test_hatch_served_setup.py -v Upgrade.test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape Upgrade.test_a_journal_one_step_from_a_writers_holds_everywhere Upgrade.test_recovery_history_is_accepted_only_as_its_writers_leave_it Upgrade.test_an_invalidated_reference_never_falls_through_to_another_success Upgrade.test_every_journal_the_real_writers_save_is_one_variant

Parent f6287e957fe12b7a8641ebb0345de62b45980b1d:

test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape (__main__.Upgrade.test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape) ... FAIL
test_a_journal_one_step_from_a_writers_holds_everywhere (__main__.Upgrade.test_a_journal_one_step_from_a_writers_holds_everywhere) ... FAIL
test_recovery_history_is_accepted_only_as_its_writers_leave_it (__main__.Upgrade.test_recovery_history_is_accepted_only_as_its_writers_leave_it) ... FAIL
test_an_invalidated_reference_never_falls_through_to_another_success (__main__.Upgrade.test_an_invalidated_reference_never_falls_through_to_another_success) ... ERROR
test_every_journal_the_real_writers_save_is_one_variant (__main__.Upgrade.test_every_journal_the_real_writers_save_is_one_variant) ... ok
======================================================================
ERROR: test_an_invalidated_reference_never_falls_through_to_another_success (__main__.Upgrade.test_an_invalidated_reference_never_falls_through_to_another_success)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<worktree>/packages/ag2-sparrow/tests/test_hatch_served_setup.py", line 3614, in test_an_invalidated_reference_never_falls_through_to_another_success
    self.assertEqual(self_upgrade.success_lineage(journal, "0.6.0"), (["0.6.0", "0.7.0"], None))
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: module 'ag2_sparrow.adapters.hatch.self_upgrade' has no attribute 'success_lineage'
======================================================================
FAIL: test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape (__main__.Upgrade.test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<worktree>/packages/ag2-sparrow/tests/test_hatch_served_setup.py", line 3493, in test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape
    self.assertEqual(sorted(accepted - written, key=repr), [], "accepted, but no writer saves it")
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: [('crashed', 'crashed', False, False, ('cr[1045 chars]lse)] != []
First list contains 16 additional elements.
First extra element 0:
('crashed', 'crashed', False, False, ('crashed', False), False)
Diff is 1225 characters long. Set self.maxDiff to None to see it. : accepted, but no writer saves it
======================================================================
FAIL: test_a_journal_one_step_from_a_writers_holds_everywhere (__main__.Upgrade.test_a_journal_one_step_from_a_writers_holds_everywhere)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<worktree>/packages/ag2-sparrow/tests/test_hatch_served_setup.py", line 3509, in test_a_journal_one_step_from_a_writers_holds_everywhere
    self.assert_held_everywhere(json.dumps(self.shaped_journal(shape)), shape)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<worktree>/packages/ag2-sparrow/tests/test_hatch_served_setup.py", line 3171, in assert_held_everywhere
    with self.assertRaises(self_upgrade.JournalError, msg=label):
         ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: JournalError not raised : ('crashed', 'crashed', False, False, ('crashed', False), False)
======================================================================
FAIL: test_recovery_history_is_accepted_only_as_its_writers_leave_it (__main__.Upgrade.test_recovery_history_is_accepted_only_as_its_writers_leave_it)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<worktree>/packages/ag2-sparrow/tests/test_hatch_served_setup.py", line 3535, in test_recovery_history_is_accepted_only_as_its_writers_leave_it
    self.assertTrue(found, f"no production writer saves {label}")
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: [] is not true : no production writer saves a failure, then a success
----------------------------------------------------------------------
Ran 5 tests in 3.843s
FAILED (failures=3, errors=1)

HEAD e1b7e39840aae48cba9b26406ceb08ed5e9574f1:

test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape (__main__.Upgrade.test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape) ... ok
test_a_journal_one_step_from_a_writers_holds_everywhere (__main__.Upgrade.test_a_journal_one_step_from_a_writers_holds_everywhere) ... ok
test_recovery_history_is_accepted_only_as_its_writers_leave_it (__main__.Upgrade.test_recovery_history_is_accepted_only_as_its_writers_leave_it) ... ok
test_an_invalidated_reference_never_falls_through_to_another_success (__main__.Upgrade.test_an_invalidated_reference_never_falls_through_to_another_success) ... ok
test_every_journal_the_real_writers_save_is_one_variant (__main__.Upgrade.test_every_journal_the_real_writers_save_is_one_variant) ... ok
----------------------------------------------------------------------
Ran 5 tests in 6.199s
OK

The writer -> accepted test passes at the parent as well, as it should: the parent rejects none of the writers' journals. Its gap was accepting journals no writer saves.

Whole file, collected counts (python3 packages/ag2-sparrow/tests/test_hatch_served_setup.py). At the parent, the two older tests fail only because their fixtures now carry installed.after and self_upgrade.RECOVERY_OUTCOMES, which the parent lacks.

PARENT f6287e957fe12b7a8641ebb0345de62b45980b1d
ERROR: test_a_journal_state_the_writer_cannot_reach_holds_restarts
ERROR: test_an_invalidated_reference_never_falls_through_to_another_success
FAIL: test_a_field_of_a_type_no_writer_saves_holds_restarts
FAIL: test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape
FAIL: test_a_journal_one_step_from_a_writers_holds_everywhere
FAIL: test_recovery_history_is_accepted_only_as_its_writers_leave_it
Ran 214 tests in 20.261s
FAILED (failures=4, errors=2)

HEAD e1b7e39840aae48cba9b26406ceb08ed5e9574f1
Ran 214 tests in 20.608s
OK

Mutation controls. Each mutation was applied to self_upgrade.py at HEAD, the five named tests were run, and the file was restored:

table: no-download NO -> MAYBE: FAILED (failures=2); failing: ['test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape', 'test_a_journal_one_step_from_a_writers_holds_everywhere']
table: restore-failed/acknowledged MAYBE -> YES: FAILED (errors=5); failing: ['test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape', 'test_a_journal_one_step_from_a_writers_holds_everywhere', 'test_an_invalidated_reference_never_falls_through_to_another_success', 'test_every_journal_the_real_writers_save_is_one_variant', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
journal: drop the matched summary's from check: FAILED (failures=3); failing: ['test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape', 'test_a_journal_one_step_from_a_writers_holds_everywhere', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
journal: drop the held restore-failed check: FAILED (failures=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
journal: drop the success's own lineage check: FAILED (failures=4); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
journal: drop each recovery's lineage check: FAILED (failures=9); failing: ['test_a_journal_is_accepted_exactly_when_a_real_writer_saves_its_shape', 'test_a_journal_one_step_from_a_writers_holds_everywhere', 'test_an_invalidated_reference_never_falls_through_to_another_success', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept a lineage not as saved: FAILED (failures=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept an equal reference: FAILED (failures=3); failing: ['test_an_invalidated_reference_never_falls_through_to_another_success', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept a cycle: FAILED (failures=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept a missing link: FAILED (failures=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept a fork: FAILED (failures=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: accept a success outside the lineage: FAILED (failures=3); failing: ['test_an_invalidated_reference_never_falls_through_to_another_success', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: fall through to another success on an equal reference: FAILED (failures=2); failing: ['test_an_invalidated_reference_never_falls_through_to_another_success', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
resolver: fall through to another success outside the lineage: FAILED (failures=2); failing: ['test_an_invalidated_reference_never_falls_through_to_another_success', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
schema: installed.after not required: FAILED (errors=1); failing: ['test_recovery_history_is_accepted_only_as_its_writers_leave_it']
writer: a success saves no lineage: FAILED (failures=3); failing: ['test_an_invalidated_reference_never_falls_through_to_another_success', 'test_every_journal_the_real_writers_save_is_one_variant', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']
writer: a success saves only its recoveries: FAILED (failures=2); failing: ['test_every_journal_the_real_writers_save_is_one_variant', 'test_recovery_history_is_accepted_only_as_its_writers_leave_it']

In-process, every entry of the variant table was also set to each other manager rule, and removed: 57 mutants. Each makes the accepted and written shape sets differ, or makes a writer's journal rejected. All 57 were caught.

Run at e1b7e398 on macOS (python3 3.14):

  • test_hatch_served_setup.py: 214 OK
  • test_hatch_adapter.py: 218 OK (skipped=2)
  • tests/workspace-lock.test.py: 30 OK
  • tests/hermetic-env-structural.test.py: 26 OK
  • tools/test_no_drift.py: PASS
  • scripts/review-checks.sh on this round's diff and on the cumulative diff: PASS
  • git diff --check: clean

CI at e1b7e398: 35 pass, 3 skipped. The first run failed in tests/skills/process-hygiene/reap-orphans.test.py, which this PR does not touch and which also fails on main CI; it passed on the rerun. At the two intermediate heads, the same kind of unrelated failure appeared in tests/skills/worker-pool/worker-config-directory.test.py, which also fails on main. At 228cc369, tests/hermetic-env-structural.test.py also caught a real problem: the test file imported itertools, which is missing from its Python 3.9 stdlib fallback list. That import was removed in ae93e998.

🤖 Generated with Claude Code

hermetic-env-structural timing (not caused by this PR). Run side by side on one machine, tests/hermetic-env-structural.test.py takes 106 s on this head and 109 s on origin/main (26 tests, OK). Two whole-tree scan tests take about 48 s each on both, so the 240 s per-file cap is a runner-capacity boundary, not a cost this PR adds.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/keepalive.py (94.5%): Missing lines 283,302,325,452,495,504,512,698
  • packages/ag2-sparrow/ag2_sparrow/adapters/hatch/self_upgrade.py (98.0%): Missing lines 357,448-449,483,496,498,503,513,519,581,632,751,757,776,891,910
  • packages/ag2-sparrow/tests/test_hatch_adapter.py (100%)
  • packages/ag2-sparrow/tests/test_hatch_served_setup.py (99.2%): Missing lines 3294,3555,4171-4174,4616,4626,4633-4638
  • src/workspace_lock.py (40.4%): Missing lines 164-176,185-193,201-204,206-207

Summary

  • Total: 2848 lines
  • Missing: 66 lines
  • Coverage: 97%

packages/ag2-sparrow/ag2_sparrow/adapters/hatch/keepalive.py

Lines 279-287

  279     """Whether `pid` runs a Sparrow command line (/proc, or sysctl on macOS); False when
  280     that cannot be read."""
  281     cmd = _proc(pid, "cmdline")
  282     if cmd is None:
! 283         return is_sparrow_argv(workspace_lock.proc_argv(pid) or [])
  284     return is_sparrow_argv([a.decode(errors="replace") for a in cmd.split(b"\0") if a])
  285 
  286 
  287 def sparrow_env(pid: int) -> "dict[str, str] | None":

Lines 298-306

  298     """A pidfd naming the process that has `pid` now; None where the platform has none
  299     (macOS). ProcessLookupError when no process has it; any other failure raises."""
  300     opener = getattr(os, "pidfd_open", None)
  301     if opener is None or not hasattr(signal, "pidfd_send_signal"):
! 302         return None
  303     return opener(pid)
  304 
  305 
  306 def _stop(pid: int, handle: "int | None" = None) -> bool:

Lines 321-329

  321         if handle is not None:
  322             import select
  323             if select.select([handle], [], [], max(0.0, deadline - time.monotonic()))[0]:
  324                 return True  # the pidfd turns readable when its process exits
! 325             continue
  326         if _cli.process_gone(pid):
  327             return True
  328         time.sleep(0.2)
  329     print(f"error: Sparrow (pid {pid}) did not exit within {STOP_WAIT_S:g}s of SIGTERM",

Lines 448-456

  448     that could not be opened) nothing is signalled: a bare pid is never trusted."""
  449     try:
  450         handle = _pidfd_open(pid)
  451     except ProcessLookupError:
! 452         return True, None
  453     except OSError as e:
  454         return False, (f"no stable process handle for pid {pid} (pidfd_open: "
  455                        f"{diagnostics.describe(e)})")
  456     if handle is None:

Lines 491-499

  491         return False, f"{unit} was not written by install, so its Sparrow is left alone", None
  492     if owner == "ours":
  493         props = _cli._unit_props()
  494         if props is None:
! 495             return False, f"systemctl --user show {_cli.SERVICE_NAME} did not give a well-formed state", None
  496         main_pid = props["MainPID"]
  497         outside = holder if holder is not None and holder != main_pid else None
  498         unproven = unproven_holder(record, outside) if outside is not None else None
  499         if unproven:

Lines 500-508

  500             return False, f"{unproven}, beside {_cli.SERVICE_NAME}; nothing was signalled", None
  501         r = _cli._systemctl(("--user", "stop", "ag2-sparrow.service"))
  502         how = f"systemctl --user stop {_cli.SERVICE_NAME}"
  503         if not _cli._ok(r):
! 504             return False, f"{how} failed ({_cli._why(r)})", "unit"
  505         if main_pid and not _wait_gone(main_pid):
  506             return False, f"{how}, but its Sparrow (pid {main_pid}) is still running", "unit"
  507         if outside is not None:
  508             stopped, unproven = stop_proven(record, outside)

Lines 508-516

  508             stopped, unproven = stop_proven(record, outside)
  509             if unproven:
  510                 return False, f"{how}; {unproven}; it was not signalled", "unit"
  511             if not stopped:
! 512                 return False, f"the Sparrow outside {_cli.SERVICE_NAME} (pid {outside}) did not stop", "unit"
  513             how += f"; stopped the Sparrow outside it (pid {outside})"
  514         manager = "unit"
  515     elif holder is None:
  516         how, manager = "Sparrow was not running", "detached"

Lines 694-702

  694             return settled or "recovery-blocked"
  695         return settled or self_upgrade.maybe_start(cfg, setup)
  696     except Exception as e:  # noqa: BLE001 - keepawake's own job must still be logged
  697         print(f"NOTE served setup: {diagnostics.describe(e)}", file=sys.stderr)
! 698         return settled
  699 
  700 
  701 def last_keepawake(log: Path) -> float | None:
  702     """The time of the log's last keepawake line, or None."""

packages/ag2-sparrow/ag2_sparrow/adapters/hatch/self_upgrade.py

Lines 353-361

  353         failed = load_state(state_dir).get("failed")
  354     except JournalError:
  355         with contextlib.suppress(OSError):
  356             return state_path(state_dir).stat().st_mtime
! 357         return time.time()
  358     if isinstance(failed, dict) and installed in (failed.get("from"), failed.get("target")):
  359         at = failed.get("at")
  360         return float(at) if isinstance(at, (int, float)) else time.time()
  361     return None

Lines 444-453

  444 
  445 def _listing(directory: Path) -> "list[str]":
  446     try:
  447         return sorted(p.name for p in Path(directory).iterdir())
! 448     except OSError:
! 449         return []
  450 
  451 
  452 def _record_scope(dist) -> dict:
  453     """The previous install, read before anything changes: the site dir, the top-level

Lines 479-487

  479     and console scripts into the scripts dir, and its whole dist-info dir (pip adds files
  480     there). ValueError for any other location, which is never installed."""
  481     found = sorted(Path(wheels).glob(f"ag2_sparrow-{version}-*.whl"))
  482     if len(found) != 1:
! 483         raise ValueError(f"expected one ag2_sparrow-{version} wheel, found {len(found)}")
  484     site, scripts = Path(scope["site"]), _scripts_dir(scope)
  485     info, data = f"ag2_sparrow-{version}.dist-info", f"ag2_sparrow-{version}.data"
  486     owned = {str(site / info)}
  487     with zipfile.ZipFile(found[0]) as zf:

Lines 492-507

  492             entry = ""
  493     for row in rows:
  494         rel = PurePosixPath(row[0]) if row and row[0] else None
  495         if rel is None:
! 496             continue
  497         if rel.is_absolute() or ".." in rel.parts:
! 498             raise ValueError(f"the wheel names a path outside its install: {row[0]}")
  499         parts = rel.parts
  500         if parts[0] != data:
  501             owned.add(str(site.joinpath(*parts)))
  502         elif len(parts) > 2 and parts[1] in ("purelib", "platlib"):
! 503             owned.add(str(site.joinpath(*parts[2:])))
  504         elif len(parts) > 2 and parts[1] == "scripts":
  505             owned.add(str(scripts.joinpath(*parts[2:])))
  506         else:
  507             raise ValueError(f"the wheel installs outside site-packages: {row[0]}")

Lines 509-517

  509     points.optionxform = str
  510     points.read_string(entry)
  511     for section in ("console_scripts", "gui_scripts"):
  512         if points.has_section(section):
! 513             owned.update(str(scripts / name) for name in points.options(section))
  514     return sorted(owned)
  515 
  516 
  517 def _files_under(path: Path) -> "list[Path]":

Lines 515-523

  515 
  516 
  517 def _files_under(path: Path) -> "list[Path]":
  518     if path.is_file():
! 519         return [path]
  520     return sorted(p for p in path.rglob("*") if p.is_file() and "__pycache__" not in p.parts) \
  521         if path.is_dir() else []
  522 

Lines 577-585

  577         name = f"{i:05d}"
  578         shutil.copy2(path, tmp / "files" / name)
  579         entries.append({"path": path, "copy": name, "sha256": digest})
  580     if not entries:
! 581         raise ValueError("the install's files are missing")
  582     dirs = _dirs_before(scope, [e["path"] for e in entries] + scope["owned"])
  583     manifest = dict(scope, version=version, files=entries, dirs=dirs)
  584     (tmp / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
  585     shutil.rmtree(dest, ignore_errors=True)

Lines 628-636

  628     removed = [Path(p) for p in namespace(manifest) if p in ours and p not in want]
  629     for path in removed:
  630         path.unlink()
  631         for pyc in path.parent.glob(f"__pycache__/{glob.escape(path.stem)}.*.pyc"):
! 632             pyc.unlink(missing_ok=True)
  633     for entry in manifest["files"]:
  634         target = Path(entry["path"])
  635         target.parent.mkdir(parents=True, exist_ok=True)
  636         shutil.copy2(snap / "files" / entry["copy"], target)

Lines 747-755

  747     problems, recorded = [], set()
  748     # RECORD is parsed here: Distribution.files drops the rows whose file is missing.
  749     for row in csv.reader((dist.read_text("RECORD") or "").splitlines()):
  750         if not row or not row[0]:
! 751             continue
  752         path = Path(os.path.normpath(dist.locate_file(row[0])))
  753         mode, _, value = (row[1] if len(row) > 1 else "").partition("=")
  754         recorded.add(path)
  755         if not value:

Lines 753-761

  753         mode, _, value = (row[1] if len(row) > 1 else "").partition("=")
  754         recorded.add(path)
  755         if not value:
  756             if path.name != "RECORD" and "__pycache__" not in path.parts:
! 757                 problems.append(f"unhashed {path}")
  758         elif not path.is_file():
  759             problems.append(f"missing {path}")
  760         elif mode != "sha256" or base64.urlsafe_b64encode(bytes.fromhex(
  761                 _sha256(path))).rstrip(b"=").decode() != value:

Lines 772-780

  772     """For the owner's install: lift a restore-failed hold once the installed files verify
  773     against their RECORD (verify_installed). None when Sparrow may start, else why not."""
  774     with upgrade_lock(state_dir) as free:
  775         if not free:
! 776             return "an upgrade is running"
  777         held = recovery_blocked(state_dir)
  778         if held is None:
  779             return None
  780         attempted = load_state(state_dir)["attempted"] if "cannot be read" not in held else {}

Lines 887-895

  887     namespace or an interrupted restore is restored, in a phase that resumes next run."""
  888     state = load_state(state_dir)
  889     for target, tried in state["attempted"].items():
  890         if not isinstance(tried, dict):
! 891             continue
  892         at, phase = tried.get("at"), tried.get("phase")
  893         stale = (tried.get("outcome") == "started" and isinstance(at, (int, float))
  894                  and (phase is not None or now - at > STALE_START_S))
  895         if not stale:

Lines 906-914

  906                                "why": why, "at": now}
  907             save_state(state_dir, state)
  908             held = recovery_blocked(state_dir) if phase == "quiesced" else None
  909             if held:
! 910                 print(f"error: Sparrow is not started: {held}", file=sys.stderr)
  911             elif phase == "quiesced":
  912                 ok, how = start_sparrow(cfg, tried.get("manager"))
  913                 print(("started: " if ok else "error: Sparrow did not start: ") + how,
  914                       file=sys.stderr if not ok else sys.stdout)

packages/ag2-sparrow/tests/test_hatch_served_setup.py

Lines 3290-3298

  3290             if journal["attempted"]["0.6.0"].get("manager") in ("unit", "detached") and \
  3291                     journal["attempted"]["0.6.0"]["phase"] in ("quiesced", "installing",
  3292                                                                "installed") and \
  3293                     "installed" not in journal and "failed" not in journal:
! 3294                 continue  # the writer's own variant, kept out of the rejected set
  3295             self.assert_held_everywhere(json.dumps(journal), journal)
  3296 
  3297     _writer_saves = None

Lines 3551-3559

  3551         for state in saved:
  3552             for m in self.mutants(state):
  3553                 key = self.full_record(m)
  3554                 if key not in written and self_upgrade._journal_fault(m, latest) is None:
! 3555                     loose[key] = m
  3556         self.assertEqual(sorted(loose), [], "accepted, but no production writer saves it")
  3557 
  3558     def test_the_one_target_projection_misses_a_mutant_the_full_record_catches(self):
  3559         failed_rb = [s for s in self.writer_saves() if len(s["attempted"]) == 1

Lines 4167-4178

  4167         gate, entered = threading.Event(), threading.Event()
  4168         fake = self.fake()
  4169 
  4170         def slow(argv, timeout):
! 4171             if FakeRun.kind(argv) == "pip-install":
! 4172                 entered.set()
! 4173                 gate.wait(10)
! 4174             return fake(argv, timeout)
  4175         outcomes = []
  4176         with mock.patch.object(self_upgrade, "_run", slow), \
  4177                 contextlib.redirect_stdout(io.StringIO()), \
  4178                 contextlib.redirect_stderr(io.StringIO()):

Lines 4612-4620

  4612                               "-m", "ag2_sparrow"])
  4613         self.addCleanup(lambda: (p.poll() is None and p.kill(), p.wait()))
  4614         deadline = time.monotonic() + 10
  4615         while not keepalive.is_sparrow_pid(p.pid) and time.monotonic() < deadline:
! 4616             time.sleep(0.05)  # until exec: a forked child still shows the parent's argv
  4617         return p
  4618 
  4619     @unittest.skipUnless(sys.platform in ("linux", "darwin"), "no start-time source")
  4620     def test_a_real_holder_is_stopped_only_with_its_own_start_time(self):

Lines 4622-4630

  4622         start = workspace_lock.proc_start(p.pid)
  4623         self.assertIsNotNone(start)
  4624         self.assertNotEqual(start, workspace_lock.proc_start(os.getpid()))
  4625         if sys.platform == "darwin":
! 4626             self.assertTrue(keepalive.is_sparrow_pid(p.pid), "argv read through sysctl")
  4627         with mock.patch.object(keepalive, "is_sparrow_pid", return_value=True), \
  4628                 contextlib.redirect_stderr(io.StringIO()):
  4629             stopped, why = keepalive.stop_proven({"pid": p.pid, "proc_start": start + "0"}, p.pid)
  4630             self.assertEqual(stopped, False)

Lines 4629-4642

  4629             stopped, why = keepalive.stop_proven({"pid": p.pid, "proc_start": start + "0"}, p.pid)
  4630             self.assertEqual(stopped, False)
  4631             self.assertIsNone(p.poll(), "a mismatched start time is never signalled")
  4632             if not hasattr(os, "pidfd_open"):  # macOS: no handle, so never signalled
! 4633                 self.assertIn("no stable process handle", why)
! 4634                 stopped, why = keepalive.stop_proven({"pid": p.pid, "proc_start": start}, p.pid)
! 4635                 self.assertEqual(stopped, False)
! 4636                 self.assertIn("no stable process handle", why)
! 4637                 self.assertIsNone(p.poll())
! 4638                 return
  4639             self.assertIn("is not proven", why)
  4640             # Reaped as it exits, as a detached Sparrow's own parent would.
  4641             threading.Thread(target=p.wait, daemon=True).start()
  4642             self.assertEqual(keepalive.stop_proven({"pid": p.pid, "proc_start": start}, p.pid),

src/workspace_lock.py

Lines 160-180

  160 
  161 
  162 def _darwin_sysctl(mib: list) -> bytes | None:
  163     """The raw value of sysctl `mib` on macOS; None when it cannot be read or is empty."""
! 164     import ctypes
! 165     import ctypes.util
! 166     try:
! 167         libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
! 168         arr, size = (ctypes.c_int * len(mib))(*mib), ctypes.c_size_t(0)
! 169         if libc.sysctl(arr, len(mib), None, ctypes.byref(size), None, 0) != 0 or not size.value:
! 170             return None
! 171         buf = ctypes.create_string_buffer(size.value)
! 172         if libc.sysctl(arr, len(mib), buf, ctypes.byref(size), None, 0) != 0:
! 173             return None
! 174         return buf.raw[:size.value] or None
! 175     except (OSError, AttributeError, TypeError):
! 176         return None
  177 
  178 
  179 # CTL_KERN, then KERN_PROC/KERN_PROC_PID, KERN_BOOTTIME and KERN_PROCARGS2 (<sys/sysctl.h>).
  180 _KERN, _KERN_PROC_PID, _KERN_BOOTTIME, _KERN_PROCARGS2 = 1, (14, 1), 21, 49

Lines 181-197

  181 
  182 
  183 def proc_argv(pid: int) -> list[str] | None:
  184     """The argv of `pid` on macOS (KERN_PROCARGS2); None elsewhere or when unreadable."""
! 185     if sys.platform != "darwin":
! 186         return None
! 187     raw = _darwin_sysctl([_KERN, _KERN_PROCARGS2, pid])
! 188     if raw is None or len(raw) < 4:
! 189         return None
! 190     argc = int.from_bytes(raw[:4], sys.byteorder)
! 191     _exe, _, rest = raw[4:].partition(b"\0")
! 192     args = rest.lstrip(b"\0").split(b"\0")
! 193     return [a.decode(errors="replace") for a in args[:argc]] if 0 < argc <= len(args) else None
  194 
  195 
  196 def proc_start(pid: int, proc: Path = Path("/proc")) -> str | None:
  197     """`<boot id>:<start time>` of `pid`, which no later process with that pid shares:

Lines 197-211

  197     """`<boot id>:<start time>` of `pid`, which no later process with that pid shares:
  198     /proc's start ticks, or on macOS kinfo_proc's p_starttime; None where it cannot be
  199     read (or the process is gone)."""
  200     if sys.platform == "darwin" and not proc.is_dir():
! 201         boot = _darwin_sysctl([_KERN, _KERN_BOOTTIME])
! 202         info = _darwin_sysctl([_KERN, *_KERN_PROC_PID, pid])
! 203         if boot is None or info is None or len(boot) < 12 or len(info) < 12:
! 204             return None
  205         # struct timeval {time_t; suseconds_t} opens both kern.boottime and kinfo_proc.
! 206         (bsec, busec), (sec, usec) = (struct.unpack_from("=qi", b) for b in (boot, info))
! 207         return f"darwin-{bsec}.{busec:06d}:{sec}.{usec:06d}" if sec > 0 else None
  208     try:
  209         boot = (proc / "sys" / "kernel" / "random" / "boot_id").read_text().strip()
  210         stat = (proc / str(pid) / "stat").read_bytes()
  211     except (OSError, ValueError):

@sonichi
sonichi force-pushed the feat/sparrow-self-upgrade branch 2 times, most recently from 146757f to 0216c11 Compare October 24, 2026 09:15

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact child head 0216c1119865be33b83e00e190f301df1272a324 over #5665 tip cad2299780b0eecf37a1794e6d1875d3b13fb130; ancestry is verified, and current main is already an ancestor of the parent. The rebase-sensitive resolutions are sound: install_service retains mxid and the bool contract while adding may_start, --no-start neither starts nor restarts, _served_setup retains diagnostics.describe, and UNIT_UNREACHABLE fails closed. I still have to request changes for three production failure-path blockers.

P1 — rollback can certify a mixed installation as rolled-back. restore() removes only paths named by whichever RECORD is visible after the failed install (self_upgrade.py:210-219), and _rollback() declares success from the imported version plus reconnect (:428-437). If pip fails after overwriting an old module and adding a target-only module while the old dist-info remains, that old RECORD cannot name the new file. Fault-injecting exactly that state through production run_upgrade() produced:

outcome=rolled-back
old_after=old
target_residue_after=True
recorded_problems=[]

The stale-attempt path has the same blind spot: _reconcile() treats current == previous as proof that no mutation occurred (:364-374). With target bytes and a target-only module beside old 0.5.4 metadata, it returned 0.6.0:crashed, made zero rollback calls, left both target changes in place, and consumed the only attempt for 0.6.0. Removing only the version guard is insufficient because the current restore still cannot remove introduced paths. Persist a pre-mutation phase/inventory that can restore the exact package namespace independently of post-failure metadata, remove additions, and verify that inventory before recording rolled-back.

P1 — the upgrader does not prove Sparrow is quiescent before changing its installed files. stop_sparrow() handles UNIT_UNREACHABLE and owner == "ours", but lets owner == "other" fall through to a raw signal (self_upgrade.py:386-399); start_sparrow() then goes through restart_listener(), which spawns detached for that same hand-written service (:402-403, keepalive.py:244-260). Exact-head probe:

other stop=(True, 'stopped Sparrow (pid 4242)') signals=[4242]
other start=(True, 'spawned')

That bypasses the existing ensure_sparrow() policy which deliberately leaves hand-made services alone (keepalive.py:401-404), and their supervisor may restart the process while pip is writing. Adjacent states fail for the same reason: an owned but inactive unit plus an external holder reports a successful unit stop without stopping the holder, and a still-live holder with a stale heartbeat becomes None/“not running.” On rollback the code proceeds even after an explicit stop failure (self_upgrade.py:411-419):

stop failed -> outcome=restore-failed restore_calls=1 install_calls=1 start_calls=1

No package mutation should occur unless the exact running process is proven stopped. Reuse one ownership/quiescence contract for the exhaustive owner/holder states, refuse unattended upgrade for owner == "other", preserve the manager across restart, and abort restoration when stop is unproven.

P1 — keepawake starts a crashed attempt before reconciling it. keepawake() calls _keep_listener() at keepalive.py:483-485, releases that block, and only then reaches _served_setup() / maybe_start() reconciliation at :488,523-529. With a stale started attempt, target metadata present, and the listener down, the observed order was:

events=['restart-unverified-target', 'reconcile-rollback']
log: listener=down restarted=1 upgrade=0.6.0:crashed

Reconcile the stale attempt under the initial upgrade lock before any listener probe/start; otherwise the unverified or mixed target can process work before rollback.

Verification at this exact head:

  • python3 packages/ag2-sparrow/tests/test_hatch_served_setup.py -v — 154/154 passed
  • python3 packages/ag2-sparrow/tests/test_hatch_adapter.py -v — 214/214 passed (2 skipped)
  • child-only and cumulative review-checks.sh — PASS; git diff --check — clean
  • hosted CI and license/cla — green

The existing stop/start test covers ours, UNIT_UNREACHABLE, and None, but omits other, owned-unit/external-holder, stale-live-holder, and rollback-after-stop-failure. Please add discriminating cases for those inputs and the two partial-install shapes above.

This PR is correctly still draft and explicitly still owes the real post-restart upgrade/delivery witness once setup transport exists; I did not restart anything. Because auto-upgrade is opt-out once a served target appears, the worst case is fleet-wide mixed environments or an unverified process handling work. Fixing these contracts and then supplying that exact-head live witness would change my verdict.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes at head 0216c111 (base feat/sparrow-served-wording @ cad22997, this PR's own diff only).

What I verified. The rebase resolutions are right. install_service keeps mxid and the bool return, and adds may_start on top. _served_setup keeps diagnostics.describe. stop_sparrow fails closed on UNIT_UNREACHABLE. With --no-start, a changed unit is stopped for the write and left stopped. One correction to the body: _cli._systemd_run still exists (cli.py:34 re-exports it). Only _service_managed is gone. The switch to _systemctl is correct either way.

A downgrade cannot be forced. _decide returns below-installed/current (self_upgrade.py:300-303), the worker re-reads under the lock (:462), and a min_version above the target is refused. pip is run with --no-deps --only-binary=:all: --require-hashes. Tests drive the real functions against a fake site-packages. They don't grep source, and there are no hardcoded paths. Mutation spot-checks: each of these fails its test when removed: the under-lock <= guard, the _decide downgrade guard, may_start and in start =, the not may_start restart veto, the UNIT_UNREACHABLE stop guard, restore-failed when problems exist, --require-hashes, and reconcile-before-decide.

Suites, run locally on python3 3.12: test_hatch_served_setup 154 OK, test_hatch_adapter 214 OK (skipped=2). The adapter suite only passes through an interpreter path without spaces. Through this host's real path (it contains a space), 41 HookScript tests fail, and 39 of them fail the same way at the base. That is a host artefact, not this PR. CI is green, and license/cla is 1 context, success.

Blocking (incomplete_fix). I independently confirm, by reading the code, the three P1s in keweichen's review at this head:

  1. restore() only removes what the post-failure RECORD names (self_upgrade.py:210-219), and _reconcile treats current == previous as "nothing changed" (:364-374).
  2. stop_sparrow SIGTERMs the lock holder under a hand-written (other) unit (:395-399). Also, _rollback restores even after the stop failed (:411-419).
  3. keepawake runs _keep_listener before _served_setup reconciles (keepalive.py:483-488), so it can restart an unverified target first.

One more problem on the same path:

  1. An interrupted crash recovery is never retried, and the unverified target stays installed. _reconcile saves outcome=crashed and failed before _rollback runs (self_upgrade.py:368-374). That rollback runs inside the keepawake process, which runs under timeout 120 (keepalive.py:51, 92). Its own budget can exceed 120 s: stop (STOP_WAIT_S 20 s, or a systemd stop timeout), install (up to 300 s), start plus _await_connected (30 s), installed_version (up to 60 s). That comes after _keep_listener has already spent up to 30 s in the same run. Probe at this head: I made _rollback raise SystemExit the way timeout's SIGTERM would, then ran maybe_start again 30 min later. The second run returned None with 0 rollback calls, outcome: crashed, and 0.6.0 still installed. The health row does show upgrade-failed, so nothing is hidden, but nothing recovers it either. If SIGTERM lands inside restore()'s unlink/copy loop, the files are left mixed. Asks: write a resumable phase (e.g. restoring) that reconcile retries, and record crashed only after the rollback has settled. Or move the recovery out of the 120 s keepawake budget (detached, like the upgrade worker). Add a test that interrupts the recovery and asserts the next run finishes it.

Not blocking, for the transport PR. target_version and target_sha256 arrive in the same served payload. The hash pin protects against a tampered index or wheel, but not against a forged setup: whoever controls that payload can pick any published version above the installed one. setup_problem does not look at package; only _decide does. Today setup_fetch has no transport, so this is dormant. The transport that lands must be authenticated end to end, and its PR should say how.

Bar. Not met: one CHANGES_REQUESTED (keweichen), no approvals. The live post-restart witness is still owed, which the body already says (named, not blocking). The PR is correctly still a draft.

— Sutando (susans-MacBook-Pro)

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at 2c138cd7 (PR is still a draft). Three of the four earlier blockers are fixed and I checked each one myself. One new blocker: the no-unit stop path can SIGTERM a process that is not Sparrow.

Verified at this head

  • P1-1 (rolled-back while new-version files remained): fixed for the files in scope. I faked a mixed install: target bytes over __init__.py, a new ag2_sparrow/sub/deep.py, a half-written ag2_sparrow-0.6.0.dist-info with a garbage RECORD, and a rewritten console script. Rollback removed the new files, put the old bytes back, and the hash inventory matched the pre-upgrade one. restore never reads RECORD. Without the removal loop, 15 tests fail. Without the drift check at the end of restore, 1 test fails.
  • 4th finding (interrupted recovery never retried): fixed. I killed a rollback in the middle of restore (SystemExit on the 2nd shutil.copy2, after the unlinks). The journal read phase: restoring, outcome: started, and drift showed a half-restored tree. On the next maybe_start the result was 0.6.0:rolled-back, the inventory matched the original, and the phase was rolled-back. Removing the _phase(..., "restoring") write fails a test.
  • P1-3 (keepawake restarting an unverified version): fixed. settle_attempt runs under the initial lock before _keep_listener. Removing it fails test_keepawake_settles_a_dead_upgrade_before_it_restarts_the_listener.
  • P1-2, "ours" branch and refusals: fixed. A hand-made unit and an unreachable user manager are refused in _decide and in quiesce_sparrow, with nothing signalled. An external holder beside our unit is stopped only when is_sparrow_pid says it is Sparrow (removing that check fails a test). An unproven stop changes no file (2 tests fail if restore runs anyway).
  • Test counts match the claim: served-setup ran 175 tests, OK. Adapter ran 214 tests, OK (2 skipped). CI is green and license/cla is success (1 context).

Blocking: the no-unit stop SIGTERMs an unverified pid (keepalive.py:400, :448)
resolve_sparrow now returns any alive pid from the lock record, whether or not its heartbeat is fresh (holder = pid if alive else None). When there is no unit, quiesce_sparrow sends that pid SIGTERM via _stop(holder) without calling is_sparrow_pid. The old code went through live_lock_holder, which only returned a pid with a fresh heartbeat. So this push widens the window: a stale lock record left after a crash names a pid the OS may have reused, and the upgrader (or a rollback) will now SIGTERM whatever process holds it and report "stopped Sparrow". Repro with the PR's own Quiesce harness: self.holder(99, fresh=False, sparrow=False) returns (True, 'stopped Sparrow (pid 99)', 'detached') with calls == [('term', 99)]. A fresh-heartbeat non-Sparrow pid gives the same result. That breaks the stated contract ("an external holder is stopped only if proven to be Sparrow"), which is so far enforced only beside our unit.
Ask: before _stop in the detached branch, require is_sparrow_pid(holder). If it is not proven Sparrow, refuse with no manager, the same as the "ours" branch. Add a test for a stale and a fresh non-Sparrow holder with no unit, asserting no term call. test_a_live_holder_with_a_stale_heartbeat_is_still_stopped (tests/test_hatch_served_setup.py:3135) should mark its pid as Sparrow explicitly, so it keeps covering the intended case.

Non-blocking

  • The namespace scope comes from the old RECORD's top-level names plus its recorded scripts (self_upgrade.py:204-236). A target that adds a new top-level package or a new console script leaves it behind, and the outcome is still rolled-back. In my fake, bin/ag2-sparrow-newcmd and ag2_sparrow_newtop/m.py survived. Consider also sweeping bin/ag2-sparrow* and ag2_sparrow* top-level names in site, or reading the target wheel's RECORD from the downloaded wheel before installing it.
  • The or phase == "restoring" clause in settle_attempt (self_upgrade.py:461) is never exercised. Removing it fails no test, because an interrupted restore still has outcome: started with a phase set, which stale already matches. Either drop the clause or add a test where it is the only route.
  • Still open from last time: the transport that serves target_sha256 must be authenticated end to end.

Kewei's CHANGES_REQUESTED at 0216c111 still stands. No approval exists at this head, so this cannot be approved yet in any case.

— Sutando (susans-MacBook-Pro)

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact child head 2c138cd74fa7a95e25c306c0640e809622d3b1ce over #5665 tip/base cad2299780b0eecf37a1794e6d1875d3b13fb130; ancestry is verified, and the cumulative main merge-base is 7940f25ff965f51df3273d94f009cb1b771119c0.

REQUEST_CHANGES — not ready to merge. The exact earlier partial-install, interrupted-restore, successful-settlement-order, and manager/refusal cases are fixed, but three P1 safety gaps remain. I independently reproduced Susan's no-unit PID finding and two adjacent recovery failures.

P1 — detached quiescence SIGTERMs an unverified PID. resolve_sparrow() accepts any alive PID from the lock record (keepalive.py:392-401), regardless of heartbeat freshness or identity. The owned-unit branch correctly requires is_sparrow_pid() for an outside holder (:431-434), but the no-unit branch calls _stop(holder) directly (:446-451). With owner=None, a stale live holder, and is_sparrow_pid=False, the production function returned:

(True, 'stopped Sparrow (pid 77)', 'detached')
calls=[('term', 77)]

The positive control beside our unit refused the same unproven holder without signalling it. A stale lock can name a reused unrelated PID, so require proof in the detached branch too and cover both fresh and stale non-Sparrow holders with zero signal calls.

P1 — rollback can still certify residue outside the old RECORD-derived scope. _record_scope() derives roots and extra solely from the pre-upgrade RECORD (self_upgrade.py:204-214), and namespace(), namespace_drift(), and restore() inspect only those roots, matching dist-info directories, and the exact old extra paths (:217-234, :269-308). A target-only top-level package plus a new console command therefore survive restore while the upgrader reports no drift:

reported_drift=[]
independent_added=[.../bin/ag2-sparrow-new-command,
                   .../site/ag2_sparrow_extra/__init__.py]

Driving the full rollback path records rolled-back with both files still present. The current partial-install tests add a module only under the already-known ag2_sparrow root, so they cannot expose this. This is the original mixed-installation failure one input to the side: make the cleanup/verification scope include target-introduced top-level packages and console scripts (or refuse such a target before mutation), and compare the result with an independent complete distribution inventory.

P1 — failed recovery still launches unverified bytes. _rollback() catches restore/hash failures at self_upgrade.py:535-538 but still calls start_sparrow() at :543-548. Injecting a restore OSError left target 0.6.0 installed and produced:

outcome=0.6.0:restore-failed
started_versions=['0.6.0']

The existing hash-mismatch test explicitly expects that restart (test_hatch_served_setup.py:2736-2751). There is a second route: keepawake() calls _keep_listener() after settlement regardless of its result (keepalive.py:550-555). With a dead mutating attempt, no usable snapshot, and the listener down:

listener=down restarted=1 upgrade=0.6.0:restore-failed
started_versions=['0.6.0']

An exception from _settle_upgrade() also returns None and reaches that restart (:593-600). A failed integrity proof must preserve the stopped state; only a verified restore should allow either the rollback start or the ordinary keepawake restart.

Verified at this head:

  • test_hatch_served_setup.py -v: 175/175 passed
  • test_hatch_adapter.py -v: 214/214 passed, 2 skipped
  • delivery-core-enforcement.test.py: 15/15 passed (the settle_attempt rename satisfies the reserved reconcile contract)
  • start-cli-private-socket-foreign-core.test.py: 4/4 passed; this is rerun evidence, not a change in this child diff
  • child-only and cumulative review-checks.sh, git diff --check, and package no-drift check: passed
  • hosted CI and license/cla: green

The PR is correctly still draft and still owes the exact-head live post-restart upgrade/delivery witness stated in its body; I did not restart a real service. Even after the code blockers are fixed, that remains an approval gate under REVIEW §15.

Worst case once setup transport activates this opt-out feature: an unattended upgrade kills an unrelated process, reports a mixed install as rolled back, or starts corrupt/unverified package bytes to process work. The three fail-closed fixes above plus the real post-restart witness are what would change this verdict.

Base automatically changed from feat/sparrow-served-wording to main October 24, 2026 16:21

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 70015e7c6a0387021eb9983ab7f5855280fa4995. The fresh/stale non-Sparrow holder case is fixed, and the five named regressions from the last round discriminate correctly (all fail at 2c138cd7, all pass here). I ran the full focused suites too: served setup 179/179, adapter 216/216 (2 skipped), cumulative review checks PASS, and git diff --check is clean.

REQUEST_CHANGES — the same fail-closed contracts still break on adjacent inputs.

P1 — a failed-recovery hold still permits unverified code to run

There are three independent routes:

  1. The documented recovery command starts the service before it clears or validates the hold. _install() calls install_service(..., may_start=True) at cli.py:746-749; that path can enable --now/restart at :627-644. Only afterward does clear_blocked() run at :754-758. clear_blocked() (self_upgrade.py:556-565) merely relabels restore-failed as acknowledged; Hatch install does not reinstall or hash-verify the package despite printing “this install put the files in place.” In the production CLI with fake systemd and a real restore-failed journal, the observed order was:

    systemctl --user enable --now ag2-sparrow.service
    service_state_at_clear=active phase_at_clear=restore-failed
    final_phase=acknowledged
    
  2. A blocked keepawake tick still calls _served_setup() unconditionally (keepalive.py:564-568), while maybe_start() has no recovery_blocked() gate (self_upgrade.py:443-480). With failed target A=0.6.0 and newly served B=0.7.0, the exact-head path produced:

    rc=1 listener_restarts=0 worker_spawns=1
    keepawake ... reason=upgrade-failed upgrade=0.7.0:started
    worker outcome=ok
    worker events=[pip-check, stop, pip-install, pip-check, install, start, version]
    installed=0.7.0
    recovery_blocked='the upgrade to 0.6.0 left unverified files (restore-failed)'
    

    The worker itself also lacks the gate, so it snapshots the already-unverified baseline and completes the install/start.

  3. After a restore/hash failure, _rollback() unconditionally calls installed_version() (self_upgrade.py:613), which starts a fresh interpreter and imports ag2_sparrow (:403-406). Injecting restore -> OSError with an unverified target __init__.py that writes a marker gave outcome=restore-failed, zero Sparrow start calls, and marker_exists=true: the diagnostic still executed the unverified package.

Please gate every service start and upgrade entry point (including the locked worker) while recovery is blocked; make the owner recovery action establish and verify known bytes before lifting the hold; and do not import package code to diagnose a failed integrity recovery.

P1 — rollback ownership is both incomplete and destructive

_record_scope() records only old RECORD roots plus top-level/bin name listings, and _owned_tops()/_owned_scripts() infer ownership from those names (self_upgrade.py:210-249). It never reads an exact target-owned inventory.

  • With pre-existing shared_vendor/foreign.py, the target RECORD added shared_vendor/sparrow_target.py. A failed reconnect drove the complete rollback path, which returned rolled-back; sparrow_target.py survived and namespace_drift() returned []. Because the top-level directory existed before and was not an old RECORD root, namespace() never sees the target-owned nested file.
  • Conversely, an unrelated foreign_added_later/keep.py and bin/foreign-tool-added-later created after the snapshot were both deleted by restore(), again with namespace_drift=[]. Every post-snapshot top-level/bin name is assumed to be Sparrow-owned, even though the upgrade lock cannot exclude another installer or owner action during a crash-recovery window.

So the new top-level test proves only one favorable shape; the policy can still falsely certify target residue and can remove foreign data. Capture an exact target-owned path inventory (for example from the target wheel/RECORD) and use it together with the old snapshot. Unknown additions must not be deleted or certified away.

P1 — a corrupt existing recovery journal fails open

load_state() maps missing, unreadable, and malformed state to the same empty dictionary (self_upgrade.py:98-106). A truncated existing hatch-upgrade.json containing {"attempted": therefore made settlement see no attempt, recovery_blocked() return false, and keepawake restart a down listener:

rc=0 restarted=1
keepawake listener=down restarted=1

An existing journal that cannot be parsed/read must preserve the stopped state and surface a repairable error; only a genuinely absent journal can mean “no recovery pending.”

P1 — command-class proof is not process ownership proof

The no-unit branch now calls is_sparrow_pid(), but that only matches argv basename/-m (keepalive.py:270-281). A stale record whose PID has been reused by a different Sparrow instance/workspace still passes and is SIGTERMed at :449-455; the docstring’s “a reused pid is never signalled” claim is therefore too strong. Bind destructive authorization to the expected state-dir/instance and process generation (or an equivalently non-reusable identity), not merely “some process whose argv looks like Sparrow.”

Finally, the required live post-restart upgrade + queued-delivery witness is still explicitly absent, so it remains an approval gate. During this review the stacked base advanced to 0776ebb58b471422e4ccc1e37f857b7149f4ef9f and GitHub now reports the PR dirty; after the blockers are fixed, rebase and rerun the cumulative checks/witness on that resulting exact head.

sonichi and others added 8 commits October 24, 2026 11:56
…ith an offline rollback

The keepawake run decides and logs whether to upgrade to the served target_version, and
starts one detached, hash-pinned upgrade per target: snapshot, stop, install, start,
verify, under STATE_DIR/upgrade.lock. Beyond the earlier review fixes:
- install --no-start never starts or restarts the service, so the re-run install cannot
  bring the target up before the upgrade's own start and checks.
- A rollback reports what happened: "rolled-back" only when the previous version is
  installed again and Sparrow reconnects, else "restore-failed" with the version installed.
- pip check that cannot run, or times out, is a failure.
- The worker re-reads the installed version under the lock and skips a target not above
  it; keepawake holds the lock across its probe and restart.
- An attempt still started after 30 minutes is settled before any decision, and the
  snapshot restored if it left another version installed.
- verify names the upgrade-failed health reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Sparrow stopped, and settles a dead attempt before any restart

Review round at 0216c11 (three P1s and an interrupted-recovery gap):
- The snapshot is the whole package namespace, hashed: the top-level names RECORD lists,
  every ag2_sparrow-*.dist-info and the console scripts. restore() removes every
  namespace path the snapshot does not list, copies it back and requires the namespace to
  hash equal to it, whatever RECORD says afterwards; "rolled-back" needs that and the
  reconnect, else "restore-failed" with the diff. Target-only files left by a partial
  install, a half-swapped dist-info, and a stale crashed attempt with target bytes beside
  old metadata are all restored to the exact pre-upgrade inventory.
- One quiescence contract in keepalive (resolve_sparrow, which ensure_sparrow now reads
  too; quiesce_sparrow): a hand-made unit or an unreachable manager is refused with nothing
  touched, and the keepawake decision names it (hand-made-service, unit-unreachable); for
  our unit the unit is stopped and its process, and any proven Sparrow outside it, must be
  gone within the bounded wait; a lock holder still alive with a stale heartbeat is stopped
  and proven gone; a holder not proven Sparrow refuses the stop. Sparrow is started only
  through the manager that was stopped. A rollback whose stop is unproven changes no file.
- keepawake settles a dead attempt under the initial lock before its listener probe or
  restart, so a restart never runs an unverified target.
- The attempt record is a journal (snapshot, quiesced, installing, installed, restoring),
  written before each step and only under the lock, so a dead attempt is settled as soon
  as the lock is free: nothing installed and no drift means crashed (Sparrow restarted if
  it had been stopped); a mutating phase, drift, or an interrupted restore is restored, and
  an interrupted restore resumes on the next run instead of staying "crashed".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…reconcile

The repository's structural rule reserves the name `reconcile` for the delivery
contract's (attempt) signature; the self-upgrade settler takes (cfg, state_dir, now)
and is a different operation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ow is stopped; the rollback namespace covers what the target adds

- quiesce_sparrow's no-unit branch requires is_sparrow_pid before SIGTERM, as the unit
  branch already did for an external holder: a reused pid behind a stale or fresh lock
  record is refused with nothing signalled and no manager.
- The namespace the snapshot and restore cover is every ag2_sparrow* top-level name in
  the site dir and every ag2-sparrow* script in the recorded scripts' directories, so a
  top-level package or console script the target introduced is removed by the rollback.
- settle_attempt's separate "restoring" clause was unreachable (an interrupted restore
  still carries outcome started with a phase set); dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and unverified files are never started

- The snapshot records the full top-level listing of the site dir and of each scripts
  directory beside the RECORD roots; namespace, drift and restore treat every name absent
  from that listing as the target's, so a top-level package or console script the target
  introduced, prefixed or not, is removed and reported, and a foreign package that was
  already there is left alone. "rolled-back" needs the listing to equal the snapshot's.
- _rollback starts Sparrow only after a verified restore; a restore or hash failure leaves
  it stopped, records restore-failed and writes the hook's notice.
- keepawake settles first and then refuses to restart the listener while the journal holds
  a mutating or restore-failed attempt, or while settlement raised; the line carries
  reason=upgrade-failed. The owner's `ag2-sparrow-hatch install` (not --no-start) lifts
  the hold; the attempt stays recorded, so the target is not retried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…olds Sparrow stopped

load_state() mapped a missing, unreadable and malformed hatch-upgrade.json to the same
empty journal, so a truncated file read as "no attempt pending" and keepawake restarted
a listener over files a dead upgrade may have left unverified. Only an absent journal now
means nothing is pending; any other unreadable shape raises JournalError, which
recovery_blocked() and keepawake turn into the upgrade-failed hold, and failed_since()
reports as a failure. The file is left in place for the owner to repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…art time match the record

quiesce_sparrow proved only the command class (argv basename or -m ag2_sparrow), so a
stale lock record whose pid had been reused by a different Sparrow instance or workspace
passed and was SIGTERMed. The workspace lock now records proc_start (boot id + start ticks
from /proc, additive to schema 1), and keepalive.unproven_holder() requires that start
time to equal the live pid's before any signal, with no unit or beside our unit. A record
without it (written before this change) or an unreadable start time is refused, with
nothing signalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… install lifts it only over verified bytes

Three routes still ran unverified code while a failed recovery held Sparrow stopped:
- install started the service (enable --now / restart) before clear_blocked(), which
  relabelled restore-failed as acknowledged without checking a byte. install now calls
  release_hold() first: the hold lifts only when every RECORD row hashes equal on disk
  and no unrecorded file sits in an ag2_sparrow* directory (verify_installed, which
  parses RECORD itself because Distribution.files drops rows whose file is missing);
  otherwise the service is installed --no-start style and install exits 1.
- keepawake still called maybe_start() while blocked, and neither maybe_start() nor the
  locked worker checked recovery_blocked(); all three now refuse ("recovery-blocked"),
  and a dead quiesced attempt is not restarted while another attempt holds.
- _rollback() imported the package (installed_version) after a failed restore or an
  unproven stop; it now reads the metadata version instead unless the restore verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t wheel's own inventory

Ownership was inferred from names: old RECORD roots, ag2_sparrow* names, and any
top-level or scripts-dir name that appeared after the snapshot. So a target file nested
in a directory that existed before (and was no old RECORD root) survived a rollback that
reported no drift, and a foreign package or script another installer added in the window
was deleted.

The worker now downloads the pinned wheel first (pip download --require-hashes) and reads
from its own RECORD and entry points every path it will install (wheel_inventory:
site-relative files, .data/purelib|platlib, .data/scripts and console scripts, its whole
dist-info dir; any other .data location refuses the upgrade). The snapshot stores that
inventory and the directories that existed; restore removes only files in the old RECORD,
the snapshot or the inventory, plus their bytecode and the directories the target
created, and installs from the downloaded wheel with --no-index. A file in an
ag2_sparrow* directory that is none of those is kept and reported as unknown drift, so a
restore never certifies it away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the feat/sparrow-self-upgrade branch from 70015e7 to b7b9b95 Compare October 24, 2026 19:29

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 67b3b908e4b6b86c492daa1817a66101a147786f. The round-4 patches fix the named happy paths, but two fail-closed contracts still have adjacent P1 gaps, so my request for changes remains.

P1 — preserve every recovery hold instead of rewriting the record that creates it

_upgrade_locked() correctly detects a held recovery, but then calls _finish(state_dir, target, "recovery-blocked") (self_upgrade.py:833-838). _finish() overwrites that target's existing phase (:719-723), while recovery_blocked() recognizes only mutating phases and restore-failed (:731-743). If the retried worker names the same target as the failed attempt, the refusal itself erases the hold.

Exact-head reproduction, with the service edge faked:

installed verification: changed __init__.py; unrecorded target-only module
initial outcome:         restore-failed
same-target worker:      recovery-blocked
hold afterward:          None
next keepawake:          exit 0, restart calls 1

The different-target control retains the original restore-failed phase, exits 1, and makes zero restart calls. The current test uses A=0.6.0 followed by B=0.7.0, so it misses the overwrite when the worker is also A. The same overwrite clears installing, installed, and restoring holds too.

The malformed-journal validation is also still container-only. These existing files both produced recovery_blocked=None, failed_since=None, and then a keepawake restart (rc=0, one restart):

{"attempted":{"0.6.0":{}}}
{"attempted":{"0.6.0":{"outcome":"started","at":"bad"}}}

Neither shape is a journal the writer can produce, so an existing but structurally incomplete attempt must fail closed just like truncated JSON. Please preserve the phase that caused a worker refusal, validate required/known attempt fields and phases, and add same-target plus incomplete-attempt regressions. This is the README's stated invariant that no restart occurs while files may be unverified.

P1 — bind identity validation to the signal, and cover the supported Darwin path

unproven_holder() compares argv and proc_start once (keepalive.py:408-420), but the detached branch later calls _stop(pid) (:470-473), which signals the bare PID with os.kill (:294-310). The owned-unit/external-holder branch has an even wider interval because systemctl stop runs between validation and _stop (:453-465). A deterministic generation-swap at that seam produced:

detached: record boot-A:100; generation at signal boot-A:999
verdict:  (True, "stopped Sparrow (pid 4242)", "detached")
signal:   SIGTERM 4242 (boot-A:999)

unit/external: unit-stop, then SIGTERM 77 (boot-A:999)
verdict:       success

So the new tuple narrows PID reuse but does not establish the promised “a reused pid is never signalled” property. Use a stable process handle from validation through signal where the platform provides one (this tree already has pidfd signaling precedent in connect_common.py:789-800), and fail closed otherwise.

There is also no native Darwin identity path. On this supported macOS host:

platform=darwin
/proc exists=false
proc_start(self)=None
lock payload has proc_start=false

is_sparrow_pid() and proc_start() both depend on /proc. macOS has no systemd user manager, so the documented normal install is detached; every current lock continues to lack the proof after restart. The disclosed 0.6.0 transition is therefore not temporary on macOS: unattended self-upgrade always reaches stop-failed. Add production-probe coverage for the Darwin behavior and either provide safe identity/quiescence there or explicitly prevent claiming/enabling the self-upgrade path on that supported platform.

Verified on this head:

  • unreadable JSON/bad top-level types now hold;
  • static start-time mismatches and missing identities are not signalled in the fixture matrix;
  • install verifies RECORD bytes before clearing restore-failed;
  • target-wheel inventory removes target-owned residue while preserving/reporting unknown namespace files;
  • test_hatch_served_setup.py: 188 passed;
  • test_hatch_adapter.py: 218 passed, 2 skipped;
  • tests/workspace-lock.test.py: 30 passed;
  • review scanner PASS; git diff --check clean; GitHub CI green.

The required real post-restart upgrade with a queued delivery is still absent, so that remains an approval gate after these code blockers are fixed.

sonichi and others added 2 commits October 24, 2026 13:09
…ncomplete attempt record fails closed

A worker refused with recovery-blocked called _finish(), which overwrote that target's
phase. When the refused worker named the same target as the held attempt, the refusal
replaced restore-failed (or installing/installed/restoring) with recovery-blocked, the
hold vanished and the next keepawake restarted unverified files. The refusal is now
read-only.

load_state() also validated only the container, so {"attempted": {"0.6.0": {}}} or an
attempt with a non-numeric "at" read as no hold. Every attempt record must now carry a
known outcome, a numeric at, and a known phase and string from when present; anything
else raises JournalError and holds Sparrow stopped. A worker run that maybe_start did not
record now writes a complete record first, so the writer cannot produce an incomplete one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and macOS records carry a start time

quiesce_sparrow proved a lock holder's argv and start time once, then sent SIGTERM to
the bare pid, so a pid reused in between (during the detached stop, or during the unit's
systemctl stop beside an outside holder) was signalled. stop_proven() now opens a pidfd
first, reads the proof after it, and signals through that pidfd: the signal reaches the
proven process or none, and the wait polls the pidfd. Where there is no pidfd (macOS, old
kernels) the proof is read immediately before the bare signal.

The proof was also /proc-only, so on macOS no lock record carried proc_start and the
detached self-upgrade always stopped at stop-failed. workspace_lock.proc_start() now reads
kern.boottime and kinfo_proc.p_starttime through sysctl on macOS, and proc_argv() reads
KERN_PROCARGS2, which is_sparrow_pid() uses when /proc is absent.

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

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes at exact head c245119af2c78dd17ed303e37ff4405c6cd4ea21. The round-5 changes fix the two concrete cases from my prior review, but the underlying fail-closed contracts still have two P1 gaps.

Verified fixed on this head:

  • A same-target recovery-blocked refusal leaves the existing hold byte-identical.
  • The newly enumerated bad attempt fields fail closed, and an externally launched worker first creates a complete record.
  • Linux opens the pidfd before proof, signals through it, and re-proves an out-of-unit holder after systemctl stop.
  • The native macOS sysctl start-time/argv probes and real-child test pass on this Darwin host.
  • All current GitHub checks are green after the rerun. Locally: served-setup 193/193, adapter 218/218 (2 skipped), workspace-lock 30/30, vendored-drift/review scans/diff check all pass.

P1 — writer-impossible journal states still clear the recovery hold

load_state() validates each field independently, makes phase optional, and accepts an existing {} even though its contract says only an absent file is empty. recovery_blocked() then consults only phase, not the outcome/state-machine relationship.

Using production recovery_blocked() and keepawake() at this head:

empty-existing-object: blocked=None rc=0 restarts=1
restore-failed-without-phase: blocked=None rc=0 restarts=1
restore-failed-with-ok-phase: blocked=None rc=0 restarts=1

A positive control with {"attempted":{"0.6.0":{}}} is rejected and produces rc=1, restarts=0, so the harness exercises the intended hold path. The missed cases are all states the atomic writer cannot produce; most critically, an explicit outcome: restore-failed can restart files the journal says were never verified.

Please validate the journal as a state machine rather than as independent enums (including requiring the top-level shape), and make a restore-failed outcome fail closed regardless of a missing/inconsistent phase. Add regressions for an existing {}, restore-failed without a phase, and restore-failed paired with a terminal phase.

P1 — the no-pidfd path still has the original check/signal race

stop_proven() falls back to _stop(pid, None), whose os.kill(pid, SIGTERM) is not bound to the generation just proved. A deterministic generation swap after the final proof shows the difference:

pidfd=true:  wrong_generation_signalled=false
pidfd=false: stop_proven=(True, None)
             signal=(SIGTERM, pid=4242, generation=boot:999)
             wrong_generation_signalled=true

This host is macOS and has no pidfd, so it takes the unsafe branch. The native sysctl identity check works, but cannot close the gap between check and signal. The new tests cover a generation swap with pidfd and a no-pidfd ordering assertion; they do not swap the process after the final no-pidfd proof. Also, _pidfd_open() turns every OSError—including resource failures such as EMFILE—into that same bare-PID fallback on Linux.

Answering the requested residual decision: yes, fail closed when there is no stable process handle. Until an identity-bound Darwin mechanism or cooperative stop exists, detached macOS self-upgrades should end stop-failed. Disabling unattended upgrade is safer than SIGTERMing an unrelated process. Unexpected pidfd-open failures must also fail closed, and the regression needs to swap generations at the final proof-to-signal seam.

Remaining approval gate

The PR is still draft and its body correctly says the required live witness is absent. After the code blockers are fixed, I still need an exact-head, real post-restart round trip: upgrade through the live service, one queued delivery surviving the stop, delivery after restart, and no repeat delivery. Please also refresh the PR body: it still says the base is #5665 although the actual base is main, stops at review round 3, and lacks round-4/5 parent-vs-head evidence and the no-pidfd guarantee/limitation.

Worst case as written: recovery can start explicitly unverified bytes, or the no-pidfd stop can send SIGTERM to an unrelated process that inherited a reused PID.

sonichi and others added 2 commits October 24, 2026 13:51
…ate machine

load_state() checked each attempt field on its own, made phase optional and accepted an
existing {} as an empty journal, and recovery_blocked() read only the phase. So {}, an
outcome restore-failed with no phase, or restore-failed beside a terminal phase such as
ok all read as no hold and keepawake restarted bytes the journal calls unverified.

The journal must now be what save_state writes: an attempted map, optional failed and
installed maps and nothing else, and each attempt at a (outcome, phase) pair the writer
produces: started with no phase or a working phase, a terminal outcome as its own phase,
or restore-failed acknowledged. Anything else raises JournalError and holds. A
restore-failed outcome holds whatever its phase unless acknowledged. A worker re-run of
an already settled target now starts a fresh record instead of journaling a working
phase over a terminal outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the stop fails closed

Without a pidfd, stop_proven() re-read the proof and then sent SIGTERM to the bare pid, so
a pid reused after that final proof was still signalled; and _pidfd_open() turned every
OSError, EMFILE included, into that same bare-pid fallback. Now only a pidfd carries the
signal: where the platform has none (macOS) or pidfd_open fails, nothing is signalled and
quiesce refuses, so a detached macOS self-upgrade ends stop-failed with Sparrow left
running the old version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecoder recursion is a JournalError

Attempt records require from and valid version keys; hook only with ok, manager only
from the quiesced phase; failed and installed summaries require every field their
writers save. Decoder recursion and any validator failure become JournalError.

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

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 5761ea4802404f2e6c8329be81d13fbca0dbbaec over 440eb9d6986561d334eb04a34321b69cd1cd79bc.

The decoder/validator-exception P1 is fixed. The specifically listed schema cases are also fixed: missing from, invalid attempted-map versions, hooks on non-ok attempts, managers in pre-quiescence phases, each required failed-summary field or one-sided outcome/problems pair, and installed summaries without hashes all hold across the consumer matrix. The three new regressions are collected and pass here; against the prior production source they produce one failure and two uncaught errors.

REQUEST_CHANGES — the broader writer-state blocker remains.

[P1] A quiesced attempt with no valid restart manager is still accepted, declared healthy, and restarted generically

_ATTEMPT accepts manager=None or any string, and _well_formed() makes the field optional. That is broader than the writer:

For started + phase="quiesced" with manager absent, null, and "bogus", each exact-head production-consumer probe returned:

load_state=accepted
recovery_blocked=None
failed_since=None
health=(healthy, None)
release_hold=None
keepawake=(rc=0, generic_restart_count=1)
journal phase: quiesced -> crashed

The manager-specific restart correctly refuses the bad value, but settlement reports no remaining hold, so the same keepawake tick falls through to _keep_listener() and performs its generic restart. This is the original failure mode: a record no writer can leave opens the restart gate and is rewritten rather than preserved.

The adjacent state variants need to be closed in the same pass. Current positive controls explicitly bless quiesced with manager=None, success with arbitrary manager="m", and rolled-back with nonempty problems; older controls also bless success without the atomically written installed summary and post-quiescence phases without a manager. The validator also accepts rolled-back with nonempty problems and restore-failed with none, although the writer chooses those outcomes from whether problems exist.

Please model the writer's actual variants rather than adding one more isolated predicate: constrain any manager to unit|detached; require it for quiesced, installing, installed, and ok (but not universally for recovery terminals); preserve the atomic success/installed relation; enforce the failure outcome/problems relation; and drive each adjacent rejected shape through the full hold/byte-identity matrix.

Verification on Python 3.13.5:

  • served setup: 208/208 pass;
  • adapter: 218/218 pass, 2 platform skips;
  • three new named regressions: 3/3 pass; prior source: 1 failure + 2 errors;
  • independent malformed-record/exception matrix: the intended cases fail closed through load, recovery, health, release, and two keepawake ticks;
  • cumulative review checks and git diff --check: pass; the hardcoded-path scan's positive control fails as expected.

CI is not green: exact-head Python shard 6/9 timed out in tests/hermetic-env-structural.test.py at its 240-second per-file cap on both attempts (rerun). The aggregate gate therefore fails and diff coverage is skipped. This looks like a whole-tree scanner/capacity boundary, but it is not proven independent of a PR that enlarges the scanned tree; it needs a green run before merge.

Worst case after transport activation: a corrupt/interrupted quiesced journal is treated as healthy, its evidence is rewritten, and keepawake starts bytes whose recovery state was not writer-valid. Draft status and the absent transport limit current exposure.

Verdict: changes requested. Fix the remaining writer-state variants and obtain green required CI. The exact-head real post-restart upgrade plus queued-delivery/no-repeat witness remains the separate approval gate already acknowledged in the PR body.

…grade writers save

Replace the field-by-field attempt checks with a table of the (outcome, phase) variants the
writers leave and the manager each carries: none before quiescence, unit or detached from
quiescence to success, optional where a restore may start un-quiesced. A success needs its
installed summary, and a failure summary's outcome must agree with its problems.

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

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head f6287e957fe12b7a8641ebb0345de62b45980b1d over 5761ea4802404f2e6c8329be81d13fbca0dbbaec.

The prior manager/phase blocker is fixed: quiesced/installing/installed/ok now require unit|detached; the listed outcome/problems and success/installed cases reject across the consumer matrix. Exact-head CI and CLA are green.

REQUEST_CHANGES — the current head still accepts complete journal records that no writer can leave.

[P1] Recovery history can lose or contradict the summary its writer saves atomically

The local attempt table is not enough. _ATTEMPT_VARIANTS admits the recovery terminals, while _well_formed_journal() validates failed only when it happens to be present. That leaves two independently reproduced holes.

First, this isolated record is accepted:

{"attempted":{"0.6.0":{"at":1.0,"from":"0.5.4","outcome":"restore-failed","phase":"acknowledged"}}}

Exact-head production-consumer probe:

load_state=accepted
recovery_blocked=None
failed_since=None
release_hold=None verify_calls=0
keepawake_1=(0, 1)
keepawake_2=(0, 1)
bytes_unchanged=True

Each keepawake tuple is (return_code, generic_restart_count). The same missing-summary shape is accepted for crashed and rolled-back; unacknowledged restore-failed initially holds, but release_hold() verifies then rewrites the malformed record to acknowledged.

No writer can leave the minimal record above. Crash/no-snapshot settlement co-writes the terminal attempt and failed before one save; rollback passes both through the same _finish() save; and clear_blocked() changes only the phase and retains the summary. The only production removal of failed is a successful upgrade, whose same save leaves installed and an ok attempt.

Second, even when failed is present, a same-run summary whose from contradicts its attempt is accepted:

attempt.from=0.5.4 failed.from=9.9.9 outcome=crashed
load_state=accepted
recovery_blocked=None
failed_since(0.5.4)=None
keepawake=(0, 1)
bytes_unchanged=True

Both recovery writers derive those two from fields from the same previous value. A mismatch can be valid only on the retained-summary branch, where a later attempt has an outcome in KEEPS_FAILED; when the attempt itself still has the summarized recovery outcome, the fields must agree.

The tests currently encode only one direction. writer_journal() adds the required companion summary for ok, but synthesizes bare recovery terminals as positive controls, and the real-writer spy proves writer ⇒ accepted, not accepted ⇒ writer-reachable. Two no-edit mutation controls both leave all 210 tests green:

no-download manager rule: NO -> MAYBE
mutant_tests=210 failures=0 errors=0 success=True

restore-failed + acknowledged manager rules: MAYBE -> YES
mutant_tests=210 failures=0 errors=0 success=True

The second mutation rejects real managerless recovery records: a pre-quiescence snapshot with drift can enter _rollback(), which writes restoring before it obtains a manager and never persists the later stopped_by; a failed restore and clear_blocked() therefore legitimately leave managerless restore-failed/acknowledged variants.

Please validate the complete reachable journal structure, not only each attempt in isolation. Preserve historical-terminal cases: multiple failures legitimately leave one current failed, and a later success can remove it while leaving an installed witness. Add both directions to the regression: every real writer state is admitted, and adjacent non-writer states are rejected through load, recovery, health, release, two keepawake ticks, and byte identity.

Verification here: served setup 210/210; adapter 218/218 with 2 platform skips; workspace-lock 30/30; cumulative review checks and git diff --check pass; hardcoded-path positive control fires. Worst case after transport activation is that malformed surviving recovery state is reported healthy and keepawake starts potentially unverified bytes. Draft status and the absent transport limit current exposure.

Verdict: changes requested; this head is not ready to approve. After the code blocker is fixed, the PR still needs the acknowledged exact-head live post-restart upgrade + queued-delivery/no-repeat witness, and the PR body needs actual current-head before/after output (it currently ends at round 6).

sonichi and others added 4 commits October 25, 2026 00:00
…leave it, across records

A crashed, rolled-back or restore-failed attempt is saved in the same write as
its `failed` summary, and only a later success drops that summary. The journal
now requires one of the two: the summary, or an `installed` record whose new
`after` list (written by the success: every recovery attempt it followed) names
the attempt. A held restore-failed never lacks its summary, and a summary that
describes its own attempt carries that attempt's `from`.

Tests now check both directions: every journal the production writers save,
across each settled history and its reruns, is accepted, and every one-target
journal shape the validator accepts is one a writer saves. Adjacent shapes and
the cross-record rows are held through load, recovery, health, release, two
keepawake ticks and byte identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved, never order or time

`installed.after` now records every attempt the journal held when the success
was saved, not only the recoveries. A recovery attempt without its summary must
be in that lineage, and so must every other `ok` attempt: one outside it came
later and would have replaced the summary. A lineage that names the success
itself, names a missing attempt, or is not sorted and unique fails closed.
Neither wall-clock times, key order nor version order is consulted.

Rows added for a success that time, key order and version call later but the
lineage does not, a second success outside the lineage, a duplicated lineage,
and a rerun that invalidates the installed reference beside an earlier success.
The test file drops its itertools import, which the 3.9 stdlib fallback in
the hermetic-env scan does not list.

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

`success_lineage(state, terminal)` walks from a surviving terminal to the
installed success through the one edge that success saved, `installed.after`,
and returns the chain it traversed with the fault that breaks it: no success,
an equal reference, a cycle, a missing link, a fork (another success outside
the lineage), a success outside the terminal's lineage, or a lineage not as its
writer saves it. It never reads times, key order or versions, and never falls
back to another success when the installed reference fails.

The validator now returns the reason it rejects a journal (`_journal_fault`),
and the regression rows assert each row's chain, fault and journal reason,
then the full hold path. A new test takes a writer-saved crash-then-success
journal, invalidates its reference while the installed record stays present,
and shows the resolver still finds that record and rejects it as unrelated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-field writer-impossible records are held; a rerun over a quiesced record keeps it valid

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

sonichi commented Oct 25, 2026

Copy link
Copy Markdown
Owner Author

Round 14 at 3923d4f, mapping each finding to its fix and test (all in test_hatch_served_setup.py; each new test fails at e1b7e39 and passes now).

  • P1 installed.after bound to a reusable version key: _attempt now removes the target from installed.after when it replaces that attempt, so an earlier success no longer counts as descending from the new record. Test: test_a_reused_version_key_is_not_an_earlier_success_descendant (production writers: rollback, 0.7.0 success, downgrade, rerun rollback, summary removed; held on load, recovery, health, release, both keepawake ticks, bytes unchanged).
  • P1 cross-field writer-impossible records: a rolled-back summary must have installed equal to from, and an ok attempt must have target above its from (new _failed_consistent and a check in _journal_fault). Test: test_a_record_no_writer_can_leave_across_its_fields_holds_restarts (full-consumer hold matrix). The shape-equivalence test's fixture was corrected to match, since its rolled-back rows used an installed that no writer saves.
  • P2 direct rerun over a quiesced record: _phase(snapshot) and _finish of a no-manager outcome drop the manager the rerun never took. Tests: test_a_direct_rerun_of_a_quiesced_attempt_never_writes_snapshot_with_a_manager and test_a_direct_rerun_keeps_a_quiesced_record_valid.

Local: test_hatch_served_setup 218 and test_hatch_adapter 218 OK. Not done: the test-audit note about the lossy one-target projection and a 3+ attempt positive history is not addressed in this commit. CI: the only red lane is python standalone tests 5/9, a >240s timeout of tests/hermetic-env-structural.test.py (passes locally in about 2 min; the same timeout is red on main runs); rerun once, same result.

…mpt production histories

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

sonichi commented Oct 25, 2026

Copy link
Copy Markdown
Owner Author

Round 14 follow-up (test audit), head below: the writer corpus now includes production-writer histories of 3+ attempts, a success followed by a download failure, and reruns after a rollback and after a success. test_the_accepted_set_is_the_writers_by_full_record compares every record exactly (only the clock and free text are scrubbed) and requires that no one-field mutation of a written journal in the fields its own record decides (what a rolled-back summary says is installed, the version a success names) is accepted unless a writer saves it. test_the_one_target_projection_misses_a_mutant_the_full_record_catches shows a rolled-back summary with a different installed version has the same one-target projection as the real one, and that with _failed_consistent disabled only the full comparison flags it. Not claimed: fields the record cannot decide alone (a crashed or restore-failed summary's installed version, an attempt's from beyond ok ordering, which in-flight attempts are in installed.after) are not mutation-checked, so global accepted-equals-writer-reachable equivalence is still not asserted.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 9ceb741c4758d3959bfc9088a780a358ffbb8395 over e1b7e39840aae48cba9b26406ceb08ed5e9574f1.

The requested repairs are real: replacing an attempt now removes its stale installed.after edge; rolled-back summaries require installed == from; the quiesced-rerun paths remove a manager they did not acquire; and equal/descending ok.from values are rejected. The four named regressions pass at HEAD and fail against the exact parent implementation (2 failures, 2 errors). The expanded writer corpus and one-target mutant demonstrate the claimed full-record distinction within their stated mutation scope. Focused suites pass (test_hatch_served_setup: 220/220; test_hatch_adapter: 218/218, 2 platform skips), and review checks plus git diff --check are clean.

REQUEST_CHANGES — one fail-closed P1 remains.

[P1] An unparseable success origin is ordered as lower than every target

_journal_fault() uses (parse_version(t["from"]) or ()) < parse_version(k). A parse failure therefore becomes the empty tuple and passes the new ordering check. At exact HEAD, an ok/ok attempt for target 0.6.0 with from: "not-a-version", manager unit, and its matching installed summary produced:

fault=None
load_state=accepted
recovery_blocked=None
failed_since=None
release_hold=None
health=('healthy', None)
keepawake=(0, 1)
bytes_unchanged=True

The keepawake tuple is (return_code, generic_restart_count). Empty and 65-character invalid strings behave the same; valid equal and higher origins are correctly rejected. This case is inside the claimed ok ordering invariant, not the disclosed residual “attempt.from beyond ok ordering.”

No success writer can leave it. _upgrade_locked() parses both target and installed origin and writes not-above-installed when either is unparseable; only after that gate and a successful install does it save the ok attempt and installed summary. A writer-impossible journal is thus reported healthy and opens the restart gate.

Please reject a missing parse result explicitly and add invalid/empty/overlong origins to the full-consumer regression and full-record mutation audit. The adjacent audit also found invalid or non-lower origins accepted in variants that prove the version gate was crossed (started phases snapshot|quiesced|installing|installed, no-pip-check, and stop-failed); preserve the legitimate pre-gate unsupported-install / not-above-installed cases while applying the invariant wherever the record proves it.

CLA is green. Hosted CI is currently red after two attempts at the same reap-orphans.test.py positive-control assertion (different cwd/exec subcases); the cumulative diff does not touch process hygiene, the focused test passes locally, and the aggregate job is red only because of that lane. It appears unrelated and timing-sensitive, but current CI is still not green. After the code blocker is fixed, the exact-head live post-restart upgrade plus queued-delivery/no-repeat witness remains the separate approval gate already recorded on this draft.

Verdict: changes requested; this head is not ready to approve.

…d-back summary is a journal fault

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

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 7c971c863a647c5b31bca5f4f921d9c464312447 over 9ceb741c4758d3959bfc9088a780a358ffbb8395.

The two changed predicates are real: the invalid ok.from and currently summarized invalid rolled-back from now fail closed through the consumer matrix. The new named test is collected and passes; reverting either production hunk makes its corresponding subcase fail. The full served-setup suite is 221/221, child review checks and git diff --check pass, and current hosted CI/CLA are green after the unrelated 5/9 rerun.

REQUEST_CHANGES — the invalid-origin P1 is only partially fixed.

[P1] Validate origins on the attempt variants that prove the version gate was crossed, including historical rollbacks

_journal_fault() checks the attempt origin only for ok; _failed_consistent() checks a rolled-back origin only while the failed summary is still present. A later success legitimately removes that summary while retaining the earlier attempt in installed.after, so an impossible invalid rolled-back origin then escapes both checks.

Exact-head no-edit repro, with a historical 0.6.0 rollback followed by a valid 0.7.0 success:

historical_rolled_back_from=not-a-version
fault=null
load=accepted
recovery_blocked=null
failed_since=null
release_hold=null
health=[healthy,null]
keepawake=[[0,1],[0,1]]
bytes_unchanged=true

The production-writer corpus generated 16 historical rolled-back records; every origin was parseable (0.5.4). A successful rollback cannot originate from an unparseable version because it needs a snapshot, and snapshot is reached only after both versions parse. This malformed journal is therefore one no writer can leave, yet it is reported healthy and opens the restart gate, contrary to the fail-closed contract in README.md:655-667.

The adjacent scope named in the previous review is still open as well. At this head _journal_fault() returns None for invalid, empty, overlong, equal, and higher origins in all six variants:

started/snapshot
started/quiesced
started/installing
started/installed
no-pip-check
stop-failed

Those variants are downstream of the strict version check and the snapshot phase's from=previous overwrite. This does not imply globally requiring from < target: a production-writer control proves a stale pre-gate attempt can legitimately roll back from a higher installed version when a retained snapshot exists. Nor should legitimate pre-gate started / unsupported-install / not-above-installed / crashed records be tightened; writer controls show that their origin can be empty or otherwise unparseable.

Please validate parseability/order according to the attempt variant that proves the gate was crossed, validate rolled-back attempts even after their summary is removed, and extend the full-consumer/full-record mutation audit beyond its current failed.installed and installed.version mutations.

Worst case after transport activation: a present journal no writer could leave is classified healthy and keepawake starts Sparrow instead of preserving an owner-visible recovery hold. This draft is not ready to approve. After the code fix, the acknowledged exact-head post-restart upgrade plus queued-delivery/no-repeat witness remains the separate approval gate.

… origin, historical ones included

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

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head 4193723badee491cee84deb70dc01d6c65c8e022 (the one-commit child of 7c971c863a647c5b31bca5f4f921d9c464312447). The two new named tests pass at this head, the gated-origin test fails when run against the parent implementation, the full focused suites pass (223/223; adapter 218/218, 2 skipped), and both child/cumulative review scans are clean.

There are still blocking writer-provenance gaps:

  1. [P1] Make the origin rule variant-aware for crashed and not-started. self_upgrade.py:173-178 excludes all crashed and not-started records from _PARSED_ORIGIN, and test_hatch_served_setup.py:3783-3794 codifies arbitrary origins for both. But a crashed/crashed record carrying a manager can only descend from started/quiesced: settle_attempt() preserves that manager at self_upgrade.py:897-901, so this variant has crossed the snapshot/version gate. At this head, a manager-bearing crash with target 0.6.0 and from: "not-a-version" loads without a journal fault, without a recovery hold, and keepawake attempts two starts. Separately, the only not-started writer first passes _decide()'s parsed, strictly-increasing version check (self_upgrade.py:803-809) and copies that same installed value into from before _finish() (861-870). A not-started record with the same malformed origin is nevertheless accepted. The early phase-less crash without a manager can remain the pre-gate exception; the writer-observable variants cannot.

  2. [P1] Validate a retained restore-failed summary independently of the replacement attempt. A later direct rerun may legitimately retain an older failure summary through KEEPS_FAILED, but _failed_consistent() only parses failed.from for rolled-back (self_upgrade.py:280-283), while the equality check at 215-217 is skipped once the current attempt has a different outcome. I loaded a journal whose current 0.6.0 attempt is valid no-download/from 0.5.4, but whose retained restore-failed summary has from: "not-a-version"; it is accepted unchanged, produces no journal/recovery fault, and keepawake attempts two starts. That summary can only have been written after rollback began, so its own origin still needs the post-gate rule even when a later attempt replaces the keyed record.

  3. [P1] Enforce the ordering half of the prior gate rule for forward-only variants. The new matrix varies only non-parseable strings (test_hatch_served_setup.py:3753-3781), and _journal_fault() still checks origin < target only for ok (self_upgrade.py:197-202). Equal/higher parseable origins are accepted for started/snapshot, started/quiesced, started/installing, started/installed, no-pip-check, and stop-failed, although each writer reaches these only after want > have at 1079-1083. For example, snapshot with 0.6.0 -> 0.6.0 and stop-failed with 0.7.0 -> 0.6.0 both load and restart here. Keep the documented higher-origin rollback case valid; this needs a forward-variant rule, not a blanket comparison.

Current hosted checks also remain red in the worker-config-directory job. The touched files and helper are byte-identical to current main and that test passed locally (4/4), so it looks unrelated, but the required hosted gate is not green. The requested real post-restart live witness is also still outstanding (the transport companion is now on main/synthetic merge, while this branch is behind it).

Verdict: REQUEST_CHANGES. Please cover the variant-aware crash/not-started cases, retained-summary provenance, and equal/higher forward-origin mutations; rerun hosted CI; then provide the live witness. I will re-review the resulting exact head.

…rget; retained restore-failed summaries and not-started and manager crashes need a version origin

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

Copy link
Copy Markdown
Contributor

@cla-assistant check

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 54b163981cb36c4e8ec2163e02449d5dd2d8cb08 against direct parent 4193723badee491cee84deb70dc01d6c65c8e022 and the current-main synthetic merge.

The three code findings from my parent-head review are resolved. The validator now:

  • requires a parsed, strictly lower origin for all eight forward-only forms (started snapshot/quiesced/installing/installed; no-pip-check; stop-failed; not-started; manager-bearing crashed), including historical attempts;
  • independently validates the origin in retained/historical restore-failed summaries; and
  • requires a parsed origin, without imposing the wrong ordering constraint, for rollback/restoring/acknowledged-restore states where a higher origin is a legitimate pre-gate recovery path.

Evidence I reproduced:

  • Exact head: test_hatch_served_setup.py ran 226/226 green; test_hatch_adapter.py ran 218 green with 2 skipped (444 tests total).
  • Current-main synthetic merge: the 226-test served-setup suite is green.
  • Overlaying the exact-head tests on the direct parent fails on forward ordering and retained-summary provenance, while the positive higher-origin rollback control remains valid.
  • An independent 98-case adversarial origin matrix has 0 mismatches at HEAD versus 34 on the parent; a 1,590-state production-writer corpus remains accepted.
  • Child-only and cumulative review-checks.sh pass, git diff --check is clean, and the added-line path scan found no production host path.
  • Exact-head hosted status is green: 35 successful Actions checks plus successful SHA-bound CLA, with 3 intentional skips. The two first-attempt failures were unrelated untouched-test/time-cap failures and passed on the same-SHA rerun.

Worst case now fails closed: a malformed or impossible journal is faulted and restart recovery remains blocked instead of silently accepting an unsafe retry. I found no remaining code-logic blocker in this head.

The remaining blocker is the required live-path evidence. The PR body still explicitly leaves the exact-head real post-restart witness unclaimed. Because this changes startup/restart and queued delivery behavior, approval still requires a real round trip at this head showing the upgrade/startup result delivered to the owner exactly once, followed by a second restart with no repeat delivery. Code review is clear to proceed to the Chi-go/live-witness step, but this PR is not merge-ready until that evidence is posted.

Verdict: REQUEST_CHANGES — code-clear; live witness only.

@sonichi

sonichi commented Oct 25, 2026

Copy link
Copy Markdown
Owner Author

Live restart witness at 54b163981cb36c4e8ec2163e02449d5dd2d8cb08

Run with the owner's go for this witness only. Both boots ran from a detached worktree at this exact head (git rev-parse HEAD = 54b163981cb36c4e8ec2163e02449d5dd2d8cb08, checked at each boot), against the live workspace and the real backend. Times are UTC, from the gateway log unless noted.

What "Sparrow" is here. On this host Sparrow is the launchd gateway job: src/launchd/gateway-bridge-wrapper.sh supervising src/remote-gateway-bridge.py, which runs ag2_sparrow.remote_gateway_bridge. For the witness the launchd job was booted out and the worktree's wrapper was started under env -i with only the plist's environment. Each boot is bound to the head in two ways: the process path is the worktree's, and the workspace lock record carries proc_start, which only this PR's workspace_lock.py writes. The restored main-checkout gateway writes no proc_start.

The item

  • item id: proactive-1792935466590.to-ag2space (owner-directed and tagged for this gateway only, written with src/result_publish.py)
  • file sha256: aeea91913eea954a45553ecf17b6006feb4e9a16d0b92a3db5501846b5a15ccd
  • posted-body sha256 (stripped): 307c42639999ea16b39d1aed2f94dfc00f6f06fe5ce1708b7e94885aa55d603c
  • body (written for the test): [#5676 witness] Test message from the Sparrow restart witness at 54b16398. Nonce w5676-7d4b4e48. It was queued while Sparrow was stopped and should arrive exactly once. No action needed.

Phase 0: queued before the restart (PASS)

13:37:34  main-checkout gateway stopped (launchd bootout; its two recorded pids gone 13:37:35)
13:37:46  item queued in results/: present, size 187, no receipt, no archive copy, no gateway running

Phase 1: first boot at the head, one delivery (PASS)

13:37:58  worktree wrapper started
13:38:00  singleton: acquired workspace poller lock (acquired)
          lock: {"pid": <boot1>, ..., "schema_version": 1, "proc_start": "darwin-<boot>:1792935479.648095"}
13:38:00  delivered proactive proactive-1792935466590.to-ag2space.txt to <owner DM>
receipt  .outbox-ag2space-proactive/.items/proactive-1792935466590.to-ag2space.<key>.json
         {"attempts": 0, "destination": "<owner DM>", "item_id": "proactive-1792935466590.to-ag2space",
          "provider": "ag2space-proactive", "reason": null, "status": "DELIVERED"}
         mtime 13:38:00, sha256 0bbdff55672861838634b8ba01f04dac5a222bd648cfa3e7a01fb4ee627504bc
archive  results/archive/proactive-1792935466590.to-ag2space-1792935480.txt, sha256 aeea9191... (= queued file)
results/ no .txt or .sending copy left
owner DM read (last 60 messages, filtered on the nonce): matches=1
         ts 1792935480797 (13:38:00.797), sent by this agent, body sha256 307c4263... (= posted body)
         event id sha256[:16] ec168f2afa070414

Phase 2: second boot at the head plus drain, no replay (PASS)

13:38:52  boot-1 wrapper stopped by its recorded pid; wrapper, bridge and sparrowd pids all gone 13:38:53
13:39:15  worktree wrapper started again (same head)
13:39:16  singleton: acquired workspace poller lock (acquired); proc_start "darwin-<boot>:1792935555.947195" (new process)
          results watcher and outbound worker (1.0 s scan) started
13:39:16-13:40:35  drain window, 79 s: 20 gateway log lines, 0 naming the item
receipt  byte-identical: sha256 0bbdff55... before and after, mtime still 13:38:00, status DELIVERED, one receipt file
results/ item not back as .txt or .sending; still one archive copy, nothing in undeliverable
owner DM read: matches=1, same ts and same event-id digest ec168f2afa070414 (no new provider event)

Restore (verified)

13:41:04  boot-2 wrapper stopped by its recorded pid; its three pids gone
13:41:05  launchd plist bootstrapped: state = running, child is the main checkout's src/remote-gateway-bridge.py
13:41:06  singleton: acquired workspace poller lock; lock record has no proc_start (main code again)
13:41:53  no process from the worktree left; core task watcher ready = yes; item still logged once in total;
          owner DM still matches=1

Gaps, stated plainly

  • This shows the PR head's delivery path across two real restarts. It does not show a self-upgrade. That stays dormant here: no transport serves agent.setup.steps yet, this host is not a Hatch install, and on macOS a detached self-upgrade refuses to stop Sparrow by design. Both restarts were wrapper stop/starts, not keepawake restarts.
  • At this head the wrapper also starts src/sparrowd.py --agent-bridges (from main's fix(launchd): the gateway wrapper keeps the declared agent bridges supervised #5763, which this branch includes). It started 0 workers and was stopped with each boot.
  • An unrelated live inbound task arrived during boot 1. Boot 2 delivered its result once. It is not part of this evidence.

@keweichen, this is the witness you asked for, at the frozen head.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rechecked exact head 54b163981cb36c4e8ec2163e02449d5dd2d8cb08 and the posted live evidence. It satisfies the exact witness requested after my prior code-clear review:

  • Phase 0 shows the owner-directed item queued while the gateway was down, with no receipt or archive.
  • Boot 1 at this head delivered it once: one delivery log, a terminal DELIVERED record, an archive whose hash equals the queued file, and exactly one provider event whose body digest matches.
  • Boot 2 at the same head then drained for 79 seconds (with a 1-second scan) without naming or replaying the item: the receipt stayed byte-identical with its original mtime, the archive count stayed one, and the provider still showed the same sole event.
  • The process path plus per-boot git rev-parse assertions bind both runs to the frozen worktree; the lock records carry this PR-only proc_start field, and the restored main process does not. The live gateway was restored afterward.

I independently recomputed the disclosed message hashes: the 186-byte stripped body is 307c42639999ea16b39d1aed2f94dfc00f6f06fe5ce1708b7e94885aa55d603c, and the 187-byte newline-terminated queued file is aeea91913eea954a45553ecf17b6006feb4e9a16d0b92a3db5501846b5a15ccd, matching the witness.

This closes the remaining worst-case concern from my review: an owner result being duplicated across a real restart. The witness explicitly does not exercise Hatch self-upgrade; that path is dormant on this non-Hatch host until setup transport exists. That limitation was disclosed and is outside the precise restart/delivery witness I requested, so it is not a new blocker at this unchanged head.

Current exact-head status is 35 successful Actions checks plus successful SHA-bound CLA, with 3 intentional skips and no pending or failed checks. My code verdict remains clear.

Verdict: APPROVE. This replaces my stale live-witness-only change request. The PR is still a draft, and another maintainer’s independent CHANGES_REQUESTED remains; the complete merge gate still applies.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at 54b16398 (PR is still a draft). The blocker from my change request at 2c138cd7 is fixed, and I checked it myself. Approving.

The no-unit stop path no longer signals an unproven pid (keepalive.py:517-523)

  • The detached branch of quiesce_sparrow now goes through stop_proven instead of a bare _stop(holder). stop_proven opens a pidfd first. It then requires is_sparrow_pid and a proc_start matching the lock record (unproven_holder, keepalive.py:429-441), and signals only through that pidfd. With no pidfd (macOS) it signals nothing. This is stricter than what I asked for.
  • My earlier repro, re-run with the PR's own Quiesce harness: holder(99, fresh=False, sparrow=False) and holder(99, fresh=True, sparrow=False) both now return (False, "pid 99 holds Sparrow's lock but is not proven to be Sparrow; nothing was signalled", None) with calls == []. A Sparrow argv with a mismatched start time is refused the same way.
  • The asked-for test exists: test_a_lock_holder_not_proven_sparrow_is_never_signalled_with_no_unit covers stale and fresh. test_a_live_holder_with_a_stale_heartbeat_is_still_stopped now passes sparrow=True explicitly.
  • Mutations: putting _stop(holder) back in the detached branch fails 5 Quiesce tests. Removing the is_sparrow_pid check from unproven_holder fails 2.
  • tests/test_hatch_served_setup.py at this head: 225 tests OK, in an isolated HOME with the RealHolder class excluded (I signal no process). CI: 36 success, 3 skipped, 0 failing. license/cla = success (1 context). No unresolved review threads.

Earlier non-blocking notes

  • New top-level names: now covered. The scope reads the target wheel's own RECORD and sweeps ag2_sparrow* entries (self_upgrade.py:476, :524).
  • Still open, not blocking: the transport that serves target_sha256 must be authenticated end to end.

Approve bar: @keweichen approved at this exact head after the live restart witness. With this review, both maintainers' change requests are cleared.

Not verified live: the witness covers delivery across two real restarts. It does not cover a Hatch self-upgrade or rollback, which is dormant on that host. That round trip is still owed once a Hatch install with setup transport exists.

— Sutando (susans-MacBook-Pro)

@sonichi
sonichi marked this pull request as ready for review October 25, 2026 14:11
@sonichi
sonichi requested a review from qingyun-wu as a code owner October 25, 2026 14:11

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve at 54b1639 as code owner for adapters/hatch/ — agent-assisted review by Mars, Qingyun's Sutando seat, on Qingyun's instruction to review and approve Chi's PRs.

I focused on the security-relevant part of self_upgrade.py: what gets installed, and on whose word.

Trust model (two keys, sound):

  • The served setup supplies package.target_version and target_sha256 (a {wheel filename: sha256} map; valid_hashes checks the names and requires 64-hex digests).
  • _pinned writes ag2-sparrow==<version> --hash=sha256:… to a temporary requirements file and runs pip with --no-deps --only-binary=:all: --require-hashes. The fetch downloads only that pinned wheel, and the install is --no-index --find-links from that directory.
  • So a compromised backend can only move a host up to an already-published ag2-sparrow wheel, and a compromised index can't serve anything whose hash the backend didn't pin. No source builds, no dependency changes, and no package name chosen by the server.

Injection: version reaches both the requirements line and wheels/<version>, but parse_version is a fullmatch on a strict PEP 440 subset (at most 64 characters, digits, dots, a/b/rc, .post, .dev), and _decide refuses anything it can't parse before pip is involved. So there's no newline, option or path traversal.

Gates: the upgrade only goes up; it's refused when min_version is above the target, when the hash map is missing, with AUTO_UPGRADE=0, for an editable or URL install, and for a hand-made or unreachable unit. One attempt per target, under upgrade.lock, re-checking the installed version.

Failure handling: a sha256 snapshot of every package file is taken before anything changes. The stop is proven (quiesce_sparrow), pip check must not newly name ag2-sparrow, and the version is verified after start. Any failure restores the snapshot offline and must match it by hash. The phase journal settles a crashed attempt, and restore-failed blocks both Sparrow and any upgrade until a RECORD-verified install lifts it.

Kewei's and Susan's approvals at this head, Kewei's live exactly-once witness across two restarts (with the dormant self-upgrade path scoped out, as stated), and green CI (39 checks) cover the runtime side. I did not run the 2,452-line served-setup suite locally.

@sonichi
sonichi merged commit ac108d7 into main Oct 25, 2026
54 of 57 checks passed
@sonichi
sonichi deleted the feat/sparrow-self-upgrade branch October 25, 2026 14:24
sonichi added a commit that referenced this pull request Oct 25, 2026
Conflict resolution: workspace_lock._payload keeps both the holder's
declares and main's proc_start (both copies identical). keepalive keeps
main's _stop(pid, handle) and stop_proven for the upgrader; this branch's
lock-proving stop becomes stop_holder(cfg, pid), which proves the holder
then calls _stop. keepawake keeps main's upgrade lock and line, and this
branch's best-effort _log_line; the health row carries both the held-task
and upgrade-failed problems. A served-setup test mocks open_tasks, the
name this branch gave oldest_open_task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Oct 26, 2026
The Hatch held-task line (#5828), Hatch self-upgrade to the served
target_version (#5676), the served-setup fetch from the broker (#5855),
served Hatch wording (#5665), seat-bridge env and token fixes (#5893,
#5859), and the claude-local/codex-local connect commands (#5860, #5761).

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.

4 participants