Repository navigation
feat(heartbeat): double-start self-guard — deterministic single writer for .alive - #2201
Conversation
….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>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 55%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
qingyun-wu
left a comment
There was a problem hiding this comment.
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:
-
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".
-
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.
|
Blocking finding:
Focused checks run: 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>
|
Fixed in 584959a — the finding is correct and the fixture proved it locally.
|
|
Confirming the Codex finding (air core) — it's correct POSIX semantics and my earlier review missed it: try:
os.kill(pid, 0)
except ProcessLookupError: # ESRCH — genuinely gone
return None
except PermissionError: # EPERM — exists, just unsignalable: yield
return pid( |
…ge gate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-verified on 584959a (air core): fix matches the agreed shape — |
|
Remaining blocking status for head
Focused checks passed locally: Reviewed by Qingyun's Codex. |
|
Approval signal for head Reviewed the final heartbeat self-guard update. No blocking findings. The previous EPERM/live-pid issue is fixed ( Focused checks passed: Reviewed by Qingyun's Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
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.pycovers 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]
|
Blocking finding for head
I reproduced this against the PR code with five forked children sharing a fresh Please make the owner acquisition atomic before entering Focused checks:
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>
|
Blocking finding addressed on
Regression test = your repro, inverted: 5 forked children through a barrier race |
|
Follow-up review for head The previous correctness blocker is resolved from my side. Ownership is now claimed before the first beat with a lifetime-held I cannot post an approval signal yet because the required Coverage Gate is red on this head: diff coverage is 89.1%, with Focused checks passed locally in an isolated 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>
|
Coverage gap closed on |
|
@cla-assistant check |
|
Approval signal for head 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 Focused checks passed in an isolated Reviewed by Qingyun's Codex. |
liususan091219
left a comment
There was a problem hiding this comment.
Justification: [change-requested: missing_before_after]
- missing_before_after: Add before/after concurrent-start output proving single writer.
|
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 ( Before ( Two writers, both surviving, and both hitting the shared-staging-file race — exactly the mid- After (this branch) — all 4 runs identical: One writer survives, the loser exits 0 with a message that says why. Note it exercised the On the final Cleanup: temp workspace removed, both processes killed, no |
Before / after — the race, and the guard's full truth table1. Staging-file race: 4 concurrent writers × 400 beats (1600 attempts)Roughly half of all beats fail with a shared staging name: one writer's Bounding the claim — I could not reproduce the JSON-corruption halfThe inline comment says interleaved writes "can leave the final That is consistent with 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 statesExactly 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 ( Method note: my first attempt at the race harness used |
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.
…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
…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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
* 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>
Problem
core_heartbeat.pyhas no double-start protection: two concurrent heartbeats write the same.aliveand 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 ofmain(): yield (exit 0 — the desired single-writer state already holds) only when.aliveis 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.--oncestays 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);--oncebypass writes our pid. All green.🤖 Generated with Claude Code