ch10 phase 9+10: permission forwarding + fg→bg promotion - #27
Merged
agentforce314 merged 1 commit intoMay 8, 2026
Merged
Conversation
…nd/background promotion
Phase 9 + Phase 10 of the chapter-10 orchestration port — the closing
chunk. Permission forwarding from worker to leader, mailbox poller with
receiver-side defense-in-depth gate, and foreground-to-background
promotion via asyncio.wait(FIRST_COMPLETED).
What lands (Phase 9 — permission forwarding):
- src/services/swarm/leader_permission_bridge.py: PermissionRequest
frozen dataclass + to_envelope. create_permission_request with
CSPRNG-backed request_id. register_permission_callback /
unregister_permission_callback / get_pending_request_ids — module-
level dict guarded by RLock. deliver_permission_decision fires the
registered callback and auto-unregisters via atomic pop; idempotent
on duplicate decisions; logged-but-not-crashing on callback
exceptions. The pop happens under the lock; the callback fires
AFTER the lock is released so callbacks may safely re-enter the
registry without deadlocking. send_permission_request_via_mailbox
writes the envelope to the leader's inbox.
- src/services/swarm/mailbox_poller.py: daemon thread with idempotent
start, ~1s tick (faster than eviction's 5s — permission-request UX
matters more than eviction lag). Per-recipient <inbox>.read_offset
cursor file so envelopes don't replay across daemon restarts.
Envelope dispatch covers 5 types:
- plain-text (envelope is None) → pending_user_messages queue
- shutdown_request → shutdown_requested=True
- plan_approval_response (lead-verified) → clears
awaiting_plan_approval + sets permission_mode
- permission_response → forwarded to deliver_permission_decision
- shutdown_response → logged (callback registry is run-loop
integration ticket, on Phase-11 backlog)
Receiver-side from=lead_agent_id defense-in-depth gate (the deferred
Chunk-F D1 + critic concern C3 from the refactoring-plan review).
When the poller sees a plan_approval_response envelope with
envelope["from"] mismatching expected_lead_agent_id, it logs a WARNING
and drops the envelope without crashing the poller or mutating
teammate state. Sender-side gate from Phase 7 + receiver-side gate
here = defense-in-depth against forged envelopes written through
any non-SendMessage path.
What lands (Phase 10 — foreground/background promotion):
- src/agent/foreground_promotion.py: register_agent_foreground /
register_agent_background / unregister_agent_foreground lifecycle
helpers — atomic mutators on the registry.
run_with_background_escape(iterator, *, background_signal,
on_background) is the direct translation of TS's Promise.race for
the chapter's foreground-to-background transition. Drives an async
iterator, races __anext__ against background_signal.wait() via
asyncio.wait(FIRST_COMPLETED), cancels the unfinished side cleanly.
Returns (messages_collected, was_backgrounded). Optional
on_background callback fires inside the bg-signal branch so the
caller can swap abort controllers / flip is_backgrounded while
state is well-defined.
Iterator exceptions on the normal path propagate to the caller
(re-raised, not silently dropped into was_backgrounded=False).
Tests (30 across 3 files):
- tests/services/swarm/test_leader_permission_bridge.py (9):
PermissionRequest dataclass shape, to_envelope serialization,
create_permission_request CSPRNG id generation, callback
registration round-trip, atomic-pop-then-fire-outside-lock,
idempotent delivery on duplicate request_id, callback exception
is logged-not-crashing, unregister cleanup, get_pending_request_ids
snapshot.
- tests/services/swarm/test_mailbox_poller.py (10): daemon lifecycle
start/stop idempotent, all 5 envelope dispatch types, per-recipient
offset cursor advances and survives restarts, 3 receiver-side
defense-in-depth gate tests (lead approves, non-lead from-claim
dropped, no-lead-configured drops).
- tests/test_foreground_promotion.py (11): chapter's 4 abort scenarios
(foreground completes naturally, bg signal during iteration promotes,
bg signal already set returns immediately, iterator exception
propagates without swallowing); register/unregister/transition state
helpers; on_background callback fires with well-defined state.
Phase 9+10 acceptance gates: permission request escalates worker →
leader mailbox; register_permission_callback allows leader's approval
to fire onAllow/onReject; mailbox poller verifies envelope from matches
lead_agent_id for plan_approval_response with log-and-drop on mismatch;
fg→bg promotion via asyncio.wait(FIRST_COMPLETED) with clean
cancellation drain on both branches; iterator exceptions propagate;
all Phase 0-8 tests still green.
This commit closes Task #3: 17/17 gap-analysis items mapped to landed
WIs across 8 chunks (Phase 0 → Phase 10). The foundational Task state
machine that the gap analysis flagged as "essentially absent in Python"
is now the typed substrate ~14 modules depend on. ~349 chapter-10
tests added; full suite runs 3650 / 25 with the 25 being pre-existing
API-key/snapshot failures unrelated to this work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8 tasks
agentforce314
approved these changes
May 8, 2026
agentforce314
left a comment
Owner
There was a problem hiding this comment.
Approving — closes the chapter-10 stack with the right shape on both phase-9 and phase-10.
What I checked
Permission forwarding (phase 9):
register_permission_callback/deliver_permission_decisionwith the atomic pop happening UNDER the lock and the callback firing AFTER the lock is released is exactly the right deadlock-avoidance shape. Callbacks that re-enter the registry would deadlock under the alternative ordering, and that re-entrance is not hypothetical — it's the natural pattern when a callback wants to register a follow-up.- Idempotent on duplicate decisions + logged-not-crashing on callback exceptions is the right resilience posture for an async permission flow where retries are realistic.
Receiver-side defense-in-depth gate:
- The
from=lead_agent_idcheck onplan_approval_responseenvelopes is the second half of the defense started in #25's sender-side gate. Crucially, it logs WARNING and drops the envelope rather than crashing — a forged/buggy envelope shouldn't be able to take down the poller daemon. Right call. - Worth adding a code comment that explicitly references the sender-side gate in
send_message.py, so the defense-in-depth picture is documented at both ends.
Mailbox poller cursor file:
<inbox>.read_offsetper-recipient cursor file handles daemon restarts correctly — without it, every poller restart would replay the inbox. One thing worth confirming (or adding a test for): that the cursor write is atomic against poller crashes — i.e. write-to-tmp + rename-into-place rather than in-place write. An in-place write that gets truncated mid-fsync could leave a corrupt cursor that re-replays from offset 0.
fg→bg promotion (phase 10):
run_with_background_escaperacing__anext__againstbackground_signal.wait()viaasyncio.wait(FIRST_COMPLETED)is the correct direct translation of TSPromise.race.- Cancelling the unfinished side cleanly is the part that goes wrong in naive ports — happy to see it called out.
- The
on_backgroundcallback firing inside the bg-signal branch (where state is well-defined) rather than at the call site is the right ergonomics for callers who need to swap abort controllers. - Iterator exception propagation is the contract you want; swallowing here would mask real errors.
All 4 abort scenarios (fg-natural / bg-during-fg / bg-already-set / iterator-exception) covered is the right test density for this primitive.
Final stack-merge note
2 tasks
singlaamitesh
pushed a commit
to singlaamitesh/clawcodex
that referenced
this pull request
Jul 7, 2026
…dination-phase9-10 ch10 phase 9+10: permission forwarding + fg→bg promotion
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 9 + Phase 10 — the closing chunk. Permission forwarding from worker to leader, mailbox poller with receiver-side defense-in-depth gate, and foreground-to-background promotion via
asyncio.wait(FIRST_COMPLETED).Phase 9 — permission forwarding:
src/services/swarm/leader_permission_bridge.py—PermissionRequest+register_permission_callback/deliver_permission_decision. Module-level dict guarded byRLock. Atomic pop happens under the lock; callback fires after the lock is released so callbacks may safely re-enter the registry without deadlocking. Idempotent on duplicate decisions; logged-not-crashing on callback exceptions.src/services/swarm/mailbox_poller.py— daemon thread with idempotent start, ~1s tick, per-recipient<inbox>.read_offsetcursor file so envelopes don't replay across daemon restarts. Dispatches 5 envelope types: plain-text, shutdown_request, plan_approval_response, permission_response, shutdown_response (logged for now — leader-side callback is on Phase-11 backlog).from=lead_agent_iddefense-in-depth gate: when the poller sees aplan_approval_responseenvelope withenvelope["from"]mismatchingexpected_lead_agent_id, it logs WARNING and drops the envelope without crashing or mutating teammate state. Sender-side gate from Phase 7 + receiver-side gate here = defense-in-depth against forged envelopes.Phase 10 — fg→bg promotion:
src/agent/foreground_promotion.py—register_agent_foreground/register_agent_background/unregister_agent_foregroundlifecycle helpers as atomic mutators on the registry.run_with_background_escape(iterator, *, background_signal, on_background)— direct translation of TS'sPromise.race. Drives an async iterator, races__anext__againstbackground_signal.wait()viaasyncio.wait(FIRST_COMPLETED), cancels the unfinished side cleanly. Optionalon_backgroundcallback fires inside the bg-signal branch so the caller can swap abort controllers / flipis_backgroundedwhile state is well-defined. Iterator exceptions propagate to the caller.30 new tests covering all 4 chapter-required abort scenarios, the 5 envelope dispatch types, the receiver-side gate (lead approves / non-lead dropped / no-lead-configured drops), and the permission callback flow.
Final PR in the chapter-10 stack. Base:
feat/ch10-coordination-phase8. Merging this completes the chapter-10 orchestration port: 17/17 gap-analysis items closed across 8 stacked PRs.Test plan
register_permission_callbackallows leader's approval to fire onAllow / onRejectfrommatcheslead_agent_idfor plan_approval_response; log-and-drop on mismatchasyncio.wait(FIRST_COMPLETED)