Skip to content

ch10 phase 9+10: permission forwarding + fg→bg promotion - #27

Merged
agentforce314 merged 1 commit into
feat/ch10-coordination-phase8from
feat/ch10-coordination-phase9-10
May 8, 2026
Merged

ch10 phase 9+10: permission forwarding + fg→bg promotion#27
agentforce314 merged 1 commit into
feat/ch10-coordination-phase8from
feat/ch10-coordination-phase9-10

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

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.pyPermissionRequest + register_permission_callback / deliver_permission_decision. Module-level dict guarded by RLock. 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_offset cursor 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).
  • Receiver-side from=lead_agent_id defense-in-depth gate: when the poller sees a plan_approval_response envelope with envelope["from"] mismatching expected_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.pyregister_agent_foreground / register_agent_background / unregister_agent_foreground lifecycle helpers as atomic mutators on the registry.
  • run_with_background_escape(iterator, *, background_signal, on_background) — direct translation of TS's Promise.race. Drives an async iterator, races __anext__ against background_signal.wait() via asyncio.wait(FIRST_COMPLETED), cancels the unfinished side cleanly. 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 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

  • 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; log-and-drop on mismatch
  • fg→bg promotion: foreground generator returns cleanly via asyncio.wait(FIRST_COMPLETED)
  • All 4 abort scenarios covered (fg-natural, bg-during-fg, bg-already-set, iterator-exception)
  • All Phase 0-8 tests still green (3650/25 full-suite, 25 pre-existing)

…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>

@agentforce314 agentforce314 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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_decision with 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_id check on plan_approval_response envelopes 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_offset per-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_escape racing __anext__ against background_signal.wait() via asyncio.wait(FIRST_COMPLETED) is the correct direct translation of TS Promise.race.
  • Cancelling the unfinished side cleanly is the part that goes wrong in naive ports — happy to see it called out.
  • The on_background callback 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

  • This being the last PR in the stack, all the CI / base-branch concerns I flagged on #21 still apply — please address them on #21 first so this final landing is on green CI rather than trust.

@agentforce314
agentforce314 merged commit 0b98970 into feat/ch10-coordination-phase8 May 8, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants