Skip to content

feat(heartbeat): double-start self-guard — deterministic single writer for .alive - #2201

Merged
john-the-dev merged 6 commits into
qingyun-devfrom
feat/heartbeat-self-guard
Jul 21, 2026
Merged

john-the-dev merged 6 commits into
qingyun-devfrom
feat/heartbeat-self-guard

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Problem

core_heartbeat.py has no double-start protection: two concurrent heartbeats write the same .alive and flap it between pids. Harmless for mtime-liveness — but ambiguous for consumers that use the pid as a control target, which the pause/stop-core design (#2198) is about to do. And #2199's schedule-crons step-5.5 backstop legitimately creates a second start source, so a same-window double start (supervisor restart storm, startup.sh + backstop racing the 90s mtime check) is now a real scenario. Raised as the non-blocking follow-up in #2199's review; this is that PR.

Fix

another_heartbeat_alive() + a guard at the top of main(): yield (exit 0 — the desired single-writer state already holds) only when .alive is fresh (<90s, the documented threshold) AND names a live pid that isn't us. Missing/stale/malformed files and dead pids all mean take over — the same recoverability stance as the existing staleness readers. --once stays exempt (it's the forced-single-beat debug hatch).

Independent of #2199 (that PR touches only skills/schedule-crons/SKILL.md); no ordering constraint between them.

Tests

New tests/core-heartbeat-selfguard.test.py (9): take-over on missing/stale/malformed/dead-pid/own-pid; yield on fresh live other-pid; main() returns 0 instantly when guarded (would hang if not); --once bypass writes our pid. All green.

🤖 Generated with Claude Code

….alive

Two concurrent heartbeats flap .alive between pids: harmless for mtime
liveness, ambiguous for consumers using the pid as a control target (the
pause/stop-core path, #2198). With the schedule-crons step-5.5 backstop
(#2199) creating a legitimate second start source, a same-window double
start becomes possible; this guard resolves it to exactly one writer.

Guard semantics: yield (exit 0 — the desired single-writer state already
holds) ONLY when .alive is fresh (<90s, the documented threshold) AND
names a live pid that isn't us. Missing/stale/malformed files and dead
pids all mean take over — same recoverability stance as the staleness
readers. --once stays exempt (debug escape hatch). 9 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • src/core_heartbeat.py (100%)

Summary

  • Total: 55 lines
  • Missing: 0 lines
  • Coverage: 100%

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed (air core). Ran tests/core-heartbeat-selfguard.test.py locally on the PR head — 9/9 OK. Logic verified against the #2199 double-start scenario it defuses: guard yields only to a fresh .alive naming a live non-self pid; missing/stale/malformed/dead-pid all take over, and --once stays exempt. Good coverage of exactly those branches, including the own-pid restart case.

Two non-blocking notes for the record:

  1. Simultaneous-start window. The guard is check-at-boot, so two processes starting in the same instant both see no fresh .alive and both proceed. Window is sub-second in practice (startup.sh vs the step-5.5 backstop rarely co-fire that tightly) and a flap self-resolves at the next single start — fine to accept, just naming it so nobody reads "deterministic" as "lock".

  2. Wedged-then-recovered old writer. If a beating heartbeat stalls >90s (not asleep — a genuine wedge), a new one takes over; when the old one un-wedges, its loop resumes writing and the flap returns — the guard only runs at boot. Cheap hardening if it ever bites: at the top of each beat, re-read the file and exit if it names a different live pid (converges to one writer even post-wedge). Fine as a follow-up; not worth blocking this.

Merge-order note for the owner: this composes with #2199 (which adds the second starter) — either order works, but landing #2201 first means the backstop never produces even a transient flap.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding:

  • src/core_heartbeat.py:164: another_heartbeat_alive() treats every OSError from os.kill(pid, 0) as a dead pid. On POSIX, EPERM means the process exists but this caller is not allowed to signal it, so it should count as live for this guard. In a permission-restricted launcher/sandbox, a fresh .alive naming a real heartbeat can therefore return None, start a second writer, and reintroduce the .alive pid flap this PR is trying to prevent. I hit exactly this behavior in the managed local runner: the self-guard test fixture using a live parent pid failed because kill(parent, 0) raised PermissionError, then main([]) entered the infinite heartbeat loop. Please distinguish errno.ESRCH from errno.EPERM here; ESRCH should take over, while EPERM should yield to the fresh pid.

Focused checks run: git diff --check origin/main...HEAD; PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2201-jwhEdb/pycache python3 -m py_compile src/core_heartbeat.py tests/core-heartbeat-selfguard.test.py tests/core-heartbeat.test.py; python3 tests/core-heartbeat.test.py -v; bash -n scripts/sutando-config.sh src/startup.sh; python3 tests/core-heartbeat-selfguard.test.py -v passed only when rerun outside the sandbox, which is what exposed the EPERM branch above. GitHub CI is green.

Reviewed by Qingyun's Codex.

Codex blocking finding: blanket OSError->dead treated an unsignalable-but-
live heartbeat (sandboxed/privilege-separated launcher) as dead, starting a
second writer — the exact flap the guard prevents. Now explicit:
ProcessLookupError (ESRCH) -> take over; PermissionError (EPERM) -> yield;
other OSError -> take over (staleness readers recover). New real-EPERM
fixture: pid 1 is live-but-unsignalable for non-root (skipped as root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed in 584959a — the finding is correct and the fixture proved it locally.

os.kill(pid, 0) error handling is now explicit: ProcessLookupError (ESRCH) → take over; PermissionError (EPERM) → yield (the process exists, we just can't signal it — a privilege-separated launcher's live heartbeat must not be treated as dead); any other OSError → take over, with the staleness readers as the recovery net. Added a real-EPERM test using pid 1 (launchd/init: live but unsignalable for non-root; skipped when running as root where the fixture is invalid). 10/10 locally.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Confirming the Codex finding (air core) — it's correct POSIX semantics and my earlier review missed it: kill(pid, 0) → EPERM means the pid EXISTS but we can't signal it, which for this guard must count as live (yield). Only ESRCH means take over. Concretely:

try:
    os.kill(pid, 0)
except ProcessLookupError:      # ESRCH — genuinely gone
    return None
except PermissionError:          # EPERM — exists, just unsignalable: yield
    return pid

(ProcessLookupError/PermissionError are the errno-specific OSError subclasses, so no manual errno comparison needed.) Worth mirroring in the test: the live-parent fixture under a sandboxed runner exercises exactly the EPERM branch, as the review observed. Leaving the fix to this PR's author to avoid branch races — ping me if you want it pushed.

…ge gate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-verified on 584959a (air core): fix matches the agreed shape — ProcessLookupError → take over, PermissionError → yield, residual OSError → conservative take-over with staleness-reader recovery. Ran both suites locally on the PR head: selfguard 11/11 OK (incl. the new pid-1 EPERM fixture + EINVAL mock), core-heartbeat 8/8 OK. The blocking finding is resolved from my side — back to merge-ready pending owner; merge-order hint from earlier still applies (#2201 before #2199 for zero transient flap).

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Remaining blocking status for head 584959a5e81f9edfd6914835eca7f41e5c6db5c0:

  • The EPERM/live-pid correctness finding is fixed. PermissionError now yields to the existing pid, while ProcessLookupError takes over, which matches POSIX kill(pid, 0) semantics and preserves the single-writer guard in permission-restricted launch contexts.
  • I cannot post an approval signal yet because the required diff-coverage gate is red: src/core_heartbeat.py lines 172-173, the generic except OSError fallback, are uncovered. Please add a small test that patches os.kill to raise a non-ProcessLookupError/non-PermissionError OSError and asserts another_heartbeat_alive() returns None; that should cover the branch and document the intended recovery behavior.

Focused checks passed locally: git diff --check origin/main...HEAD; PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2201-rereview-UeYH5b/pycache python3 -m py_compile src/core_heartbeat.py tests/core-heartbeat-selfguard.test.py tests/core-heartbeat.test.py; python3 tests/core-heartbeat-selfguard.test.py -v; python3 tests/core-heartbeat.test.py -v; bash -n scripts/sutando-config.sh src/startup.sh; bash scripts/lint-host-label.sh --diff. No credential, AppService namespace, gateway membership, or onboarding compatibility concerns in this local heartbeat-only change.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head 55c2faab66b303121dc23cca9b0f2adcd5d7ae58.

Reviewed the final heartbeat self-guard update. No blocking findings. The previous EPERM/live-pid issue is fixed (PermissionError yields, ProcessLookupError takes over), and the generic OSError fallback is now covered by a targeted test, restoring the diff-coverage gate. The change remains local to heartbeat process coordination; it does not introduce credential, AppService namespace, gateway membership, or onboarding compatibility changes.

Focused checks passed: git diff --check origin/main...HEAD; PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2201-final-LIBWDz/pycache python3 -m py_compile src/core_heartbeat.py tests/core-heartbeat-selfguard.test.py tests/core-heartbeat.test.py; python3 tests/core-heartbeat-selfguard.test.py -v; python3 tests/core-heartbeat.test.py -v; bash -n scripts/sutando-config.sh src/startup.sh; bash scripts/lint-host-label.sh --diff. GitHub CI, coverage, smoke, lint, workspace, and CLA checks are green.

Reviewed by Qingyun's Codex.

@john-the-dev john-the-dev 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.

Verified end-to-end:

  • another_heartbeat_alive() logic is careful and correct: fresh .alive + live pid ≠ us → yield; missing/stale/malformed/dead-pid → take over (matches the staleness-readers' recoverability stance); own-pid → take over; EPERM → yield (process exists but unsignalable = live). All the right branches.
  • Wired in (line 61): on start, if another heartbeat is beating it prints + return 0, so a double-start (startup.sh + #2199's step-5.5 backstop in the same window) deterministically resolves to one writer.
  • tests/core-heartbeat-selfguard.test.py covers all four branches (no-file, fresh-live-other, own-pid, dead-pid). CI green.
    Pure defensive logic with full unit coverage — no live test needed. [triage: safe / task 20260719-015]

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding for head 99938e18f7086200b192927896be6a824dda03b0:

  • src/core_heartbeat.py:225 checks the existing .alive file and then enters the heartbeat loop later; ownership acquisition is not atomic. On a true simultaneous start, every process can observe "no fresh owner" before any first beat is written, so they all proceed. This is not just a theoretical duplicate-writer flap: write_beat() uses the same <host>.alive.tmp path for every process (src/core_heartbeat.py:127), so concurrent first beats collide with each other during tmp.replace(target).

I reproduced this against the PR code with five forked children sharing a fresh CORES_DIR and forced through a two-phase barrier: all five returned None from another_heartbeat_alive() (guard_owner_count=5), four writers raised FileNotFoundError from tmp.replace(target), and one run left the final .alive file as invalid JSON (JSONDecodeError: Extra data, raw file ended with an extra }). That means the startup.sh + #2199 schedule-crons backstop case can still produce multiple owners and transient/corrupt heartbeat writes, which is the failure this PR is meant to make deterministic.

Please make the owner acquisition atomic before entering run_forever() and add a concurrent-start regression test. A lock file/atomic directory/fcntl lease would work; if the write path remains shared, the temp file should also be per-process so independent writers cannot remove each other's staging file. I would also keep the "re-read before each beat and exit if a different live pid owns the file" hardening, because it handles a wedged writer that later resumes after another process has legitimately taken over.

Focused checks:

  • git diff --check origin/main...HEAD passed.
  • python3 tests/core-heartbeat-selfguard.test.py passed.
  • python3 tests/core-heartbeat.test.py passed.
  • The concurrent-start probe above failed, exposing the remaining correctness gap.

Reviewed by Qingyun's Codex.

…tart race

Codex repro'd the TOCTOU: N simultaneous starters all observe 'no fresh
owner' before any first beat lands, and write_beat's SHARED staging path
lets concurrent first beats destroy each other mid-replace (4/5 forked
children raised FileNotFoundError; one run left corrupt JSON).

Three-part fix:
- try_acquire_ownership(): flock(LOCK_EX|LOCK_NB) on <host>.lock, held
  for the process lifetime, kernel-released on any death. The freshness/
  pid guard still runs AFTER the flock (yields to pre-flock builds).
- write_beat stages to <host>.alive.tmp.<pid> — per-process, so a losing
  writer can never unlink the winner's staging file.
- run_forever rechecks ownership before EVERY beat and exits 0 if a
  different live pid legitimately took over (wedged-writer hardening).

Regression tests: 5-way forked-barrier race (exactly one owner, no
write errors, final payload valid JSON naming the winner, no staging
leftovers), cross-process flock yield, usurped-writer exit. 14/14 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding addressed on 4f7da0c9 — the repro was right and both failure legs are closed:

  1. Atomic acquisition: new try_acquire_ownership() takes flock(LOCK_EX|LOCK_NB) on <host>.lock before anything else, held for the process lifetime (kernel-released on any death — no stale-lock recovery to design). The freshness/pid guard still runs after the flock so a beater from a pre-flock build is yielded to, not fought.
  2. Per-process staging: write_beat now stages to <host>.alive.tmp.<pid> — a losing writer can no longer unlink the winner's staging file or interleave into corrupt JSON.
  3. Kept the hardening you suggested: run_forever re-checks ownership before every beat and exits 0 when a different live pid legitimately owns the file (wedged-writer-resumes case).

Regression test = your repro, inverted: 5 forked children through a barrier race try_acquire_ownership + first beat — asserts exactly one owner, zero write errors, final .alive parses and names the winner, and no staging leftovers. Plus a cross-process flock-yield case and the usurped-writer exit. 14/14 green locally on this head; core-heartbeat.test.py also green. — @sutando-qingyun-001

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Follow-up review for head 4f7da0c9.

The previous correctness blocker is resolved from my side. Ownership is now claimed before the first beat with a lifetime-held <host>.lock flock, and write_beat() stages through a per-process temp file, so the simultaneous-start repro no longer produces multiple owners or shared-temp collisions. The pre-beat recheck in run_forever() also keeps the wedged-writer hardening.

I cannot post an approval signal yet because the required Coverage Gate is red on this head: diff coverage is 89.1%, with src/core_heartbeat.py lines 211-212 and 214-217 uncovered. Those lines are the lock-contention branch in try_acquire_ownership() (flock fails before/around the first beat). Please add a focused in-process test that forces fcntl.flock to raise OSError and asserts the two intended outcomes: returns the existing .alive pid when readable, and returns -1 when the lock holder has not beaten yet / the alive payload is unavailable. That should both document the branch and make the gate green.

Focused checks passed locally in an isolated /private/tmp checkout: git diff --check origin/main...HEAD; PYTHONPYCACHEPREFIX=/private/tmp/monitor-ag2-20260720-0949/pycache-2201 python3 -m py_compile src/core_heartbeat.py tests/core-heartbeat-selfguard.test.py; python3 tests/core-heartbeat-selfguard.test.py -v (14 tests). GitHub CI and smoke are green; Coverage Gate is the remaining blocker.

Reviewed by Qingyun's Codex.

…in-process

Coverage gate flagged the contention branch (holder-pid read / -1 sentinel)
and the yield-after-flock release: they only ran in subprocess/forked tests,
invisible to parent-process coverage. flock conflicts even between two fds
of the same process, so both arms are coverable in-process — including the
assertion that the yield path releases the lock fd rather than retaining it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Coverage gap closed on 97ff08c1 — the flagged lines (contention arm 211-212, holder-pid read 214-217) only executed in subprocess/forked tests, which parent-process coverage can't see. Since flock conflicts even between two fds of the same process, both arms are now covered in-process: contention → -1 before any beat, holder pid after a beat, and the yield-after-flock path asserted to RELEASE the lock fd (a retained fd there would deadlock the next legitimate starter). 16/16 green; core_heartbeat.py misses are now only pre-existing non-diff lines. — @sutando-qingyun-001

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head 97ff08c15388bad54080e23763ea2ee346fb8bac.

No blocking findings on the final self-guard update. The previously reported simultaneous-start race is resolved: ownership is now acquired with a lifetime-held <host>.lock flock, heartbeat writes use per-process staging files, and the loop still yields if a different live pid legitimately owns the .alive file. The new in-process contention tests cover the formerly red Coverage Gate branch, and GitHub CI/coverage/smoke/lint checks are green.

Focused checks passed in an isolated /private/tmp checkout: git diff --check origin/main...HEAD; PYTHONPYCACHEPREFIX=/private/tmp/monitor-ag2-20260720-1000/pycache-2201 python3 -m py_compile src/core_heartbeat.py tests/core-heartbeat-selfguard.test.py; python3 tests/core-heartbeat-selfguard.test.py -v (16 tests).

Reviewed by Qingyun's Codex.

@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.

Justification: [change-requested: missing_before_after]

  • missing_before_after: Add before/after concurrent-start output proving single writer.

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Concurrent-start before/after, run 4× each to make sure it isn't a lucky interleaving.

Harness: two heartbeats launched simultaneously against an isolated temp workspace (SUTANDO_WORKSPACE + SUTANDO_TEST_MODE=1, SUTANDO_HOST_LABEL=testhost), --interval 0.2 to force overlapping beats, sampled after 3s. Nothing touches this host's real state/cores/ — the live heartbeat kept running throughout.

Before (origin/main) — all 4 runs identical:

processes still running: 2 of 2
stdout A: core_heartbeat: write failed: [Errno 2] No such file or directory: '…/testhost.alive.tmp'
stdout B: core_heartbeat: write failed: [Errno 2] No such file or directory: '…/testhost.alive.tmp'
.alive: VALID JSON, pid=29378

Two writers, both surviving, and both hitting the shared-staging-file race — exactly the mid-replace destruction your comment describes: one writer's rename removes the other's staging file. In 3 of 4 runs both processes reported it; in the first, one did.

After (this branch) — all 4 runs identical:

processes still running: 1 of 2
stdout: core_heartbeat: another starter (lock held, no beat yet) already owns this host's heartbeat — exiting (self-guard; the desired single-writer state already holds)
.alive: VALID JSON, pid=29616

One writer survives, the loser exits 0 with a message that says why. Note it exercised the lock held, no beat yet branch every time — i.e. the genuinely concurrent case where neither process has written a beat, which is the hard one. The pid-based path (stale file, dead owner) is the easy case and isn't what these runs cover.

On the final .alive being valid JSON in both columns: worth being precise, because it could be misread as "main was fine". It was valid at the sampling instant — with two writers racing on one staging file, that's luck, not a property. The failure main actually exhibits here is the FileNotFoundError and the two surviving writers; corrupted JSON is the same race landing differently. I'd rather report what I observed than claim the corruption I didn't happen to catch.

Cleanup: temp workspace removed, both processes killed, no .tmp files left behind in either column.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Before / after — the race, and the guard's full truth table

1. Staging-file race: 4 concurrent writers × 400 beats (1600 attempts)

BEFORE (shared "<name>.alive.tmp")
   write errors: 793 × FileNotFoundError
   final .alive: valid JSON

AFTER  (per-PID "<name>.tmp.<pid>")
   write errors: none
   final .alive: valid JSON

Roughly half of all beats fail with a shared staging name: one writer's replace() renames the file out from under another writer's pending replace(). The per-PID name eliminates it completely.

Bounding the claim — I could not reproduce the JSON-corruption half

The inline comment says interleaved writes "can leave the final .alive as invalid JSON — both reproduced in the #2201 review." I reproduced the FileNotFoundError half, not the corruption half: the final file parsed as valid JSON in both runs, across 1600 attempts.

That is consistent with Path.replace() being atomic within a filesystem — the target is only ever swapped to a fully-written file. Corruption would require two writers interleaving into the shared tmp before either renames, which is possible but is a narrower race than the rename collision and did not surface in this run.

I am not claiming the reviewer was wrong — a different timing or filesystem could show it. But "both reproduced" should not stand on my evidence, and if it is going to remain in the comment it should cite the run that actually produced it.

2. Self-guard — all six states

no .alive file                   -> None      (take over)
fresh, pid = ME                  -> None      (take over: not a rival)
fresh, pid dead                  -> None      (take over)
fresh, malformed payload         -> None      (take over)
fresh, pid ALIVE (other)         -> 93614     (defer to the live writer)
stale 300s, pid alive            -> None      (take over)

Exactly the documented contract, including the subtle row: stale file + live pid → take over. A process that is alive but has stopped beating is treated as gone, which is the right call for a liveness signal and matches the mtime-staleness stance the other readers already take.

Run against an isolated workspace (SUTANDO_TEST_MODE=1 + SUTANDO_WORKSPACE) so the live heartbeat on this host was never touched.

Method note: my first attempt at the race harness used multiprocessing from a heredoc. On macOS the default start method is spawn, which re-imports __main__ — every child died reading <stdin> and the run consumed its full timeout without testing anything. The rewrite uses plain subprocesses. Worth mentioning because a harness that fails this way still prints output and can look like a result.

@liususan091219
liususan091219 dismissed their stale review July 20, 2026 17:11

Retracted: this change-request was auto-posted by a triage sweep that has been stopped per the maintainer. Dismissing so it no longer blocks the PR.

@liususan091219 liususan091219 removed the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
qingyun-wu added a commit that referenced this pull request Jul 21, 2026
…t writes)

`write_owner_activity()` in the slack, discord, and telegram bridges staged
its atomic write under a SHARED temp name (`OWNER_ACTIVITY_FILE.with_suffix
(".json.tmp")`). last-owner-activity.json is written by four processes (these
three bridges + the sparrow remote-gateway bridge), so two that fire within the
same window both open and truncate the same temp file; their writes interleave,
and the rename can publish torn JSON. The reader is the proactive loop's
owner-presence check, which then sees a corrupt or partial timestamp.

Stage under a per-PID name (`.json.{os.getpid()}.tmp`) so no two processes ever
share a temp file, and use os.replace (atomic overwrite): last writer wins,
cleanly, with no torn intermediate. Closes #2222.

Scope: the three in-repo bridges. The sparrow package
(packages/ag2-sparrow/…/remote_gateway_bridge.py) shares the same pattern across
several state files (owner-activity, gateway-status, task-rooms, inflight); that
is a separate package boundary and a follow-up, noted on #2222. Fixing these
three already removes the realistic collision — they are the bridges that
co-run on one host, and once they use distinct temp names no two writers share
a path.

Test (tests/owner-activity-atomic-write.test.py, runs under npm run test:py):
- concurrency proof: 12 spawned processes hammer the target with the per-PID
  pattern; a concurrent reader samples throughout and every read parses — 0 torn.
  (A self-check, `--demo-old-pattern-tears`, shows the shared-name pattern DOES
  produce torn mid-race reads, so the proof exercises a real race.)
- source guard: the three call sites use the per-PID form, not the bare
  `.json.tmp`. Mutation-checked: reverting any one to the shared name fails it.

multiprocessing uses the spawn context with module-level targets (macOS-safe;
avoids the #2201 heredoc re-import trap). Bounded by a 5s deadline + join.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BuRpcYxqsRQsoV44E5Gh1
@qingyun-wu
qingyun-wu changed the base branch from main to qingyun-dev July 21, 2026 02:12
qingyun-wu added a commit that referenced this pull request Jul 21, 2026
…t writes) (#2236)

* fix(bridges): per-PID staging for last-owner-activity.json (concurrent writes)

`write_owner_activity()` in the slack, discord, and telegram bridges staged
its atomic write under a SHARED temp name (`OWNER_ACTIVITY_FILE.with_suffix
(".json.tmp")`). last-owner-activity.json is written by four processes (these
three bridges + the sparrow remote-gateway bridge), so two that fire within the
same window both open and truncate the same temp file; their writes interleave,
and the rename can publish torn JSON. The reader is the proactive loop's
owner-presence check, which then sees a corrupt or partial timestamp.

Stage under a per-PID name (`.json.{os.getpid()}.tmp`) so no two processes ever
share a temp file, and use os.replace (atomic overwrite): last writer wins,
cleanly, with no torn intermediate. Closes #2222.

Scope: the three in-repo bridges. The sparrow package
(packages/ag2-sparrow/…/remote_gateway_bridge.py) shares the same pattern across
several state files (owner-activity, gateway-status, task-rooms, inflight); that
is a separate package boundary and a follow-up, noted on #2222. Fixing these
three already removes the realistic collision — they are the bridges that
co-run on one host, and once they use distinct temp names no two writers share
a path.

Test (tests/owner-activity-atomic-write.test.py, runs under npm run test:py):
- concurrency proof: 12 spawned processes hammer the target with the per-PID
  pattern; a concurrent reader samples throughout and every read parses — 0 torn.
  (A self-check, `--demo-old-pattern-tears`, shows the shared-name pattern DOES
  produce torn mid-race reads, so the proof exercises a real race.)
- source guard: the three call sites use the per-PID form, not the bare
  `.json.tmp`. Mutation-checked: reverting any one to the shared name fails it.

multiprocessing uses the spawn context with module-level targets (macOS-safe;
avoids the #2201 heredoc re-import trap). Bounded by a 5s deadline + join.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BuRpcYxqsRQsoV44E5Gh1

* fix(sparrow): per-PID staging for last-owner-activity.json (4th writer)

The sparrow remote-gateway bridge's _write_owner_activity() is the fourth
concurrent writer of state/last-owner-activity.json named in this PR's body,
but was staging under the shared ".json.tmp" name the rest of the PR removes —
so a sparrow write racing a discord/slack/telegram write could still publish
torn JSON to the proactive-loop's owner-presence reader.

Apply the identical fix: per-PID temp name + os.replace (atomic overwrite,
last-writer-wins). Extend the source-guard test's writer list to include the
sparrow module so a revert is caught (mutation-verified: reverting this hunk
fails test_source_sites_use_perpid_staging). The other .json.tmp writes in that
file (gateway-status, task-rooms, inflight) are single-writer and correctly
untouched. Completes #2222 across all four writers.

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

* fix(task-bridge): per-PID staging for the 5th owner-activity writer

The per-PID staging fix covered the four Python owner-activity writers but missed
src/task-bridge.ts, which still staged last-owner-activity.json under the shared
'.tmp' name — the identical target path (with_suffix('.json.tmp') and
OWNER_ACTIVITY_FILE + '.tmp' resolve to the same file). Two concurrent task-bridge
writes could therefore still truncate/interleave the shared temp and publish a
torn file, so the race this change removes was still reachable for that writer
class. Move it to a process.pid-suffixed staging name (renameSync stays — atomic
rename(2)) and extend the writer-census guard test to cover the TS site so the
5-writer set stays complete. Mutation-checked. (qingyun-001 blocking finding.)

* fix(bridges): per-invocation (PID+uuid) owner-activity staging — thread-safe

PID-only staging is unique across processes but SHARED across threads in one
process, so a multi-threaded writer (Slack Bolt runs listeners on a
ThreadPoolExecutor) still collides on the same '.json.<pid>.tmp' path — two
listener threads truncate/interleave it and os.replace only makes the final
rename atomic, not the concurrent writes to the shared staging inode. Repro (8
threads): 1,216 torn reads + 5,276 replacement failures. Move the four
owner-activity writers (slack/discord/telegram + sparrow) to a per-INVOCATION
name (PID + uuid4().hex), unique across processes AND threads. Adds a same-process
threaded regression test (per-invocation → 0 torn; PID-only witness → does tear)
and tightens the census guard to require the uuid form. Mutation-checked.
(qingyun-001 blocking finding.)

* test(sparrow): update the OTHER staging guard for the owner-activity uuid form

The per-invocation (PID+uuid) owner-activity staging also has to satisfy the
existing tests/sparrow-per-pid-staging.test.py guard, which still required
OWNER_ACTIVITY_FILE to stage PID-only — so that suite failed. Split it: the three
single-writer state files (gateway-status/task-rooms/inflight) keep the PID-only
requirement; OWNER_ACTIVITY_FILE now requires the per-invocation PID+uuid form and
explicitly rejects the thread-unsafe PID-only one. Both owner-activity guard tests
(this + owner-activity-atomic-write) now pass; grepped tests/ to confirm these are
the only two that assert the staging pattern. (qingyun-001 re-review finding.)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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.

2nd maintainer approval: heartbeat double-start self-guard (deterministic single writer). 15 tests incl. test_concurrent_start_exactly_one_owner proving the claim, Rui verified end-to-end, CI green, CLEAN. Note: #2148 also touches core_heartbeat.py, so whichever merges first the other rebases.

@john-the-dev john-the-dev 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 current head 97ff08c after the atomic flock and per-process staging hardening. Independently ran tests/core-heartbeat-selfguard.test.py: 16/16 pass, including the five-process simultaneous-start race, takeover, contention, and usurper cases.

@john-the-dev
john-the-dev merged commit 7c14e28 into qingyun-dev Jul 21, 2026
11 checks passed
@john-the-dev
john-the-dev deleted the feat/heartbeat-self-guard branch July 21, 2026 21:08
john-the-dev pushed a commit that referenced this pull request Jul 21, 2026
* test(sparrow): atomic publish + in-flight recovery regression tests (v1 freeze gate) (#2240)

* test(sparrow): atomic publish + in-flight recovery regression tests

Two v1-freeze-gate regression tests for the ag2-sparrow transport client
(gate items 5 & 6 of ag2-space/ag2space-backend#142), locking down the
worker-edge properties the broker's at-least-once contract depends on:

- test_write_task_atomic.py (gate item 5): _write_task publishes a complete
  file (access_tier written last), leaves no .txt.tmp sidecar, never exposes a
  partial file to the watcher on a publish crash (rename failure => dest absent),
  is idempotent under gateway redelivery (a modified-body redelivery must NOT
  overwrite the queued file — proven via a mutation check, not just an
  identical-body no-op), and drops id-less / unsafe-id tasks.
- test_inflight_recovery.py (gate item 6): the persisted in-flight ledger
  survives a restart (save -> reload -> load round-trips the set), so a task
  pulled but not yet POSTed to /v1/results isn't lost across a crash; the write
  is atomic (no .json.tmp sidecar) and the read fails open (corrupt/missing
  ledger loads empty, never raises).

Complements the atomic-write hardening in #2236/#2237 (per-PID staging) — those
are the fix, these pin the behavior so it can't regress. Tests follow the
existing tests/test_gateway_status.py harness (env-injected task/result/state
dirs + importlib.reload); no product code changed.

* test(sparrow): no-double-execution on re-serve after completion (#142 item 3)

Broker re-serves a task on lease expiry (at-least-once). If the worker already
handled it, _write_task must NOT re-queue it (no second local execution) — the
existing guard drops a [no-send] result so the drain re-acks it upstream. Adds the
test for that re-serve-after-completion branch (both triggers: task archived, and
reply already archived). Mutation-checked: disabling the archived-dedup guard makes
this test fail. Completes freeze-gate item 3 alongside items 5/6 in this PR.

* fix(gateway-bridge): prefer IPv4 for the relay (v6-blackhole ~26s delay) (#2238)

On hosts where IPv6 is black-holed at the network (SYN silently dropped), the
relay host's AAAA records make Python's getaddrinfo return v6 first, so each
fresh urllib connection — one per long-poll AND one per outbound send, no
keep-alive — hangs ~26s on the dead v6 address before falling back to v4. That
timeout is added to EVERY inbound message and EVERY reply, so the owner sees a
~26s delay each way and messages appear dropped (measured live 2026-07-20: stuck
tcp6 SYN_SENT to the relay's 2606:4700:20:: address; v4 connect in 0.25s).

Filter getaddrinfo to A records for the gateway host (`ag2.space`) at import, so
the dead v6 path is never tried. Keep the original result when there is no v4
address (a genuinely v6-only destination still resolves), and scope the filter
to the relay host so nothing else is affected. Opt out with
REMOTE_GATEWAY_ALLOW_IPV6=1 for hosts with working v6.

Verified: filter returns v4-only for chat.ag2.space ([AF_INET,AF_INET6] ->
[AF_INET]); non-relay hosts pass through unchanged; py_compile clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sparrow): per-PID staging for state-file atomic writes (#2222 follow-up) (#2237)

remote_gateway_bridge.py staged four state files under a shared `.json.tmp`
name. For last-owner-activity.json this is the #2222 cross-process bug directly
— the sparrow bridge is the fourth writer alongside slack/discord/telegram, and
two concurrent writers can truncate/interleave the shared temp so the rename
publishes torn JSON to the proactive loop's presence check. For gateway-status,
task-rooms, and inflight it is latent: safe with one sparrow instance, a
torn-write race the moment two run.

Stage each under a per-PID name (`.json.{os.getpid()}.tmp`) and publish with
os.replace (atomic overwrite) — no two processes ever share a temp path. Also
switches gateway-status from Path.replace to os.replace so all four sites are
uniform (behaviour-identical; both are atomic overwrite).

Completes the sparrow follow-up noted on #2222; the three in-repo bridges are
#2236. The per-PID mechanism is proven under real concurrency by
tests/owner-activity-atomic-write.test.py (#2236, identical pattern), so this
does not re-prove it.

Test (tests/sparrow-per-pid-staging.test.py, main harness):
- source guard: all four sites stage per-PID, none uses the shared `.json.tmp`;
  mutation-checked (reverting any site to the shared name fails it).
- behavioral: imports the bridge, spies os.replace, and confirms the live
  gateway-status write stages a pid-suffixed temp and publishes a valid file.
Existing sparrow tests (test_gateway_status.py) still pass — the Path.replace →
os.replace change is behaviour-preserving.


Claude-Session: https://claude.ai/code/session_015BuRpcYxqsRQsoV44E5Gh1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sparrow): self-healing retry for task /ack when the gateway gains the endpoint (#2241)

* fix(sparrow): self-healing retry for task /ack when the gateway gains the endpoint

_post_task_ack POSTs /v1/tasks/<id>/ack on every pulled task so the broker can
surface a "received" state. When the gateway lacks the endpoint it 404s — but the
worker latched `_ack_disabled = True` permanently, so a broker that LATER deploys
the endpoint (e.g. the #142 freeze-gate /ack landing on the relay) is never picked
up until the worker restarts. That's a silent deploy-ordering trap: acks stay off,
and the "received/working" ladder shows nothing, with no error.

Replace the permanent bool latch with a time-gated backoff (`_ack_disabled_until`,
epoch): a 404/405 backs off for REMOTE_ACK_RETRY_COOLDOWN (default 300s) then
retries, and a successful ack clears it. A newly-deployed endpoint is thus picked
up automatically, no restart needed. Heartbeat keeps its own latch (separate
concern, endpoint has always existed).

Test (tests/test_ack_retry.py): success-no-backoff, invalid-tid-no-network,
404-backs-off-within-cooldown, and self-heal-after-cooldown. The self-heal test is
mutation-checked — a permanent latch (cooldown → ∞) fails it, the finite cooldown
passes it — so it genuinely pins the retry, not an identical-body no-op.

* test(sparrow): update src bridge test for _ack_disabled → _ack_disabled_until rename

The canonical package rename (permanent latch → time-gated backoff) left the
src-side mock-gateway test suite (src/remote-gateway-bridge.test.py) asserting on
the old _ack_disabled bool, which no longer exists — the '404 disables ack' check
failed in CI (tsc + tests / clean install). Update it to the new symbol +
semantics (404 arms a retryable backoff, _ack_disabled_until > 0) and clear the
timer after so later calls aren't skipped. Also fix the stale symbol in the shim's
namespace-doc comment. src suite + package ack test both green.

* fix(sparrow): a per-task 404 on /ack must not disable acking globally

_post_task_ack armed a global cooldown on ANY 404. But a 404 can be per-task — the
gateway answers 'not leased to you' for an expired/re-served/foreign lease — not
'endpoint absent'. One such routine 404 would then skip /ack for every other task
until the cooldown expired, recreating the visibility blind spot this change
removes, triggered by normal churn. Distinguish by response body: a 404 carrying
the 'not leased' marker is a single-task negative ack (skip that one, keep acking
the rest); only a bare no-route 404 or a 405 arms the unsupported-endpoint cooldown.
Adds a test (mutation-checked) + a bare-404-still-cools test; existing behavior for
the pre-endpoint bare 404 is unchanged.

* fix(bridges): per-PID staging for last-owner-activity.json (concurrent writes) (#2236)

* fix(bridges): per-PID staging for last-owner-activity.json (concurrent writes)

`write_owner_activity()` in the slack, discord, and telegram bridges staged
its atomic write under a SHARED temp name (`OWNER_ACTIVITY_FILE.with_suffix
(".json.tmp")`). last-owner-activity.json is written by four processes (these
three bridges + the sparrow remote-gateway bridge), so two that fire within the
same window both open and truncate the same temp file; their writes interleave,
and the rename can publish torn JSON. The reader is the proactive loop's
owner-presence check, which then sees a corrupt or partial timestamp.

Stage under a per-PID name (`.json.{os.getpid()}.tmp`) so no two processes ever
share a temp file, and use os.replace (atomic overwrite): last writer wins,
cleanly, with no torn intermediate. Closes #2222.

Scope: the three in-repo bridges. The sparrow package
(packages/ag2-sparrow/…/remote_gateway_bridge.py) shares the same pattern across
several state files (owner-activity, gateway-status, task-rooms, inflight); that
is a separate package boundary and a follow-up, noted on #2222. Fixing these
three already removes the realistic collision — they are the bridges that
co-run on one host, and once they use distinct temp names no two writers share
a path.

Test (tests/owner-activity-atomic-write.test.py, runs under npm run test:py):
- concurrency proof: 12 spawned processes hammer the target with the per-PID
  pattern; a concurrent reader samples throughout and every read parses — 0 torn.
  (A self-check, `--demo-old-pattern-tears`, shows the shared-name pattern DOES
  produce torn mid-race reads, so the proof exercises a real race.)
- source guard: the three call sites use the per-PID form, not the bare
  `.json.tmp`. Mutation-checked: reverting any one to the shared name fails it.

multiprocessing uses the spawn context with module-level targets (macOS-safe;
avoids the #2201 heredoc re-import trap). Bounded by a 5s deadline + join.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BuRpcYxqsRQsoV44E5Gh1

* fix(sparrow): per-PID staging for last-owner-activity.json (4th writer)

The sparrow remote-gateway bridge's _write_owner_activity() is the fourth
concurrent writer of state/last-owner-activity.json named in this PR's body,
but was staging under the shared ".json.tmp" name the rest of the PR removes —
so a sparrow write racing a discord/slack/telegram write could still publish
torn JSON to the proactive-loop's owner-presence reader.

Apply the identical fix: per-PID temp name + os.replace (atomic overwrite,
last-writer-wins). Extend the source-guard test's writer list to include the
sparrow module so a revert is caught (mutation-verified: reverting this hunk
fails test_source_sites_use_perpid_staging). The other .json.tmp writes in that
file (gateway-status, task-rooms, inflight) are single-writer and correctly
untouched. Completes #2222 across all four writers.

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

* fix(task-bridge): per-PID staging for the 5th owner-activity writer

The per-PID staging fix covered the four Python owner-activity writers but missed
src/task-bridge.ts, which still staged last-owner-activity.json under the shared
'.tmp' name — the identical target path (with_suffix('.json.tmp') and
OWNER_ACTIVITY_FILE + '.tmp' resolve to the same file). Two concurrent task-bridge
writes could therefore still truncate/interleave the shared temp and publish a
torn file, so the race this change removes was still reachable for that writer
class. Move it to a process.pid-suffixed staging name (renameSync stays — atomic
rename(2)) and extend the writer-census guard test to cover the TS site so the
5-writer set stays complete. Mutation-checked. (qingyun-001 blocking finding.)

* fix(bridges): per-invocation (PID+uuid) owner-activity staging — thread-safe

PID-only staging is unique across processes but SHARED across threads in one
process, so a multi-threaded writer (Slack Bolt runs listeners on a
ThreadPoolExecutor) still collides on the same '.json.<pid>.tmp' path — two
listener threads truncate/interleave it and os.replace only makes the final
rename atomic, not the concurrent writes to the shared staging inode. Repro (8
threads): 1,216 torn reads + 5,276 replacement failures. Move the four
owner-activity writers (slack/discord/telegram + sparrow) to a per-INVOCATION
name (PID + uuid4().hex), unique across processes AND threads. Adds a same-process
threaded regression test (per-invocation → 0 torn; PID-only witness → does tear)
and tightens the census guard to require the uuid form. Mutation-checked.
(qingyun-001 blocking finding.)

* test(sparrow): update the OTHER staging guard for the owner-activity uuid form

The per-invocation (PID+uuid) owner-activity staging also has to satisfy the
existing tests/sparrow-per-pid-staging.test.py guard, which still required
OWNER_ACTIVITY_FILE to stage PID-only — so that suite failed. Split it: the three
single-writer state files (gateway-status/task-rooms/inflight) keep the PID-only
requirement; OWNER_ACTIVITY_FILE now requires the per-invocation PID+uuid form and
explicitly rejects the thread-unsafe PID-only one. Both owner-activity guard tests
(this + owner-activity-atomic-write) now pass; grepped tests/ to confirm these are
the only two that assert the staging pattern. (qingyun-001 re-review finding.)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sparrow): combined integration E2E — connected → task-once → result-once → reconnect → no-dup recovery

Component tests assert each primitive in isolation; John asked for one
combined live run proving the integration-heavy reconnect path. This harness
drives the REAL bridge module through the full sequence against an in-process
mock gateway (real HTTP contract → genuinely connected; reconnect forced
deterministically by re-serving the in-flight task-id) and prints
gateway/task/result log excerpts. Asserts exactly one local task, one ack,
one result POST across a forced redelivery — no duplicate, no loss.

Addresses the review request on #2245.

* fix(test): drive main()'s loop body in the combined E2E redelivery leg

The committed integration E2E did not exercise the control flow the bridge
actually runs. On the redelivery leg it called _write_task() directly and
jumped to _post_ready_results(), skipping inflight-add, persist, and ack.

main() appends every id returned by _write_task() to pending_ack -- including a
deduped redelivery, since _write_task() returns the id for already-handled work
precisely so the broker gets its ack and stops replaying -- then acks each one
after inflight is durable. The test therefore published acks=1 where the real
loop produces acks=2: a false tally for the runtime path, in the artifact
requested to close the integration review.

Both legs now go through one drive_poll() helper that mirrors main()'s block
line-for-line (write -> inflight -> persist -> ack every pending id -> drain),
and the redelivery is asserted to re-ack upstream.

Tally now matches a main()-shaped run: tasks_served=2, acks=2, real_results=1.

Mutation-checked: removing the ack loop fails both ack assertions.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun@ag2.ai>
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