Skip to content

fix(escalation): wait full quorum timeout instead of resolving on first vote#3255

Open
SemTiOne wants to merge 11 commits into
microsoft:mainfrom
SemTiOne:fix/escalation-quorum-wait-timeout
Open

fix(escalation): wait full quorum timeout instead of resolving on first vote#3255
SemTiOne wants to merge 11 commits into
microsoft:mainfrom
SemTiOne:fix/escalation-quorum-wait-timeout

Conversation

@SemTiOne

@SemTiOne SemTiOne commented Jul 4, 2026

Copy link
Copy Markdown

Description

EscalationHandler.resolve() woke on the first quorum vote's event and ran the quorum check exactly once, falling through to default_action before later votes could arrive. An M-of-N quorum with M > 1 could never be satisfied. InMemoryApprovalQueue.wait_for_quorum() now loops: it re-checks quorum after every vote and re-arms the event inside the same lock approve()/deny() hold before calling set(), so a vote landing in that window is never lost.

While reproducing this, found approve()/deny() only fired the wakeup event on the vote that first set req.decision. Later votes were recorded in req.votes but never woke a blocked resolve(). Left unfixed, wait_for_quorum()'s loop would still land on the correct decision, but only by blocking for the entire timeout_seconds and re-checking votes at the deadline, which defeats the point of this fix. Both methods now fire on every accepted vote, not just the first.

Scope is intentionally limited to resolve()/wait_for_quorum()/_quorum_outcome() plus this event-firing fix in approve()/deny(). It does not touch the vote-recording/dedup/empty-approver logic landed via #3126. That's unrelated to this bug and already in main.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Maintenance (dependency updates, CI/CD, refactoring)
  • Security fix

Package(s) Affected

  • agent-os-kernel
  • agent-mesh
  • agent-runtime
  • agent-sre
  • agent-governance
  • docs / root

Checklist

  • My code follows the project style guidelines (ruff check)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (pytest)
  • I have updated documentation as needed
  • I have signed the Microsoft CLA

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

Prior art / related projects:
wait_for_quorum()'s loop follows the same re-check-predicate-and-re-arm pattern as Python's threading.Condition.wait_for(). Implemented by hand here instead of migrating to Condition to avoid touching InMemoryApprovalQueue.approve()/deny()'s already-landed logic from #3126 beyond the minimal event-firing fix.

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

Claude (Anthropic) was used for root-cause investigation, drafting the fix, and test design. All changes were reviewed, verified against a live upstream checkout (not just the fork snapshot), and re-tested by reverting each individual fix in isolation to confirm the regression test actually discriminates the bug it targets, before submission.

IP, Patents, and Licensing

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

Related Issues

Fixes #3186

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

Welcome to the Agent Governance Toolkit! Thanks for your first pull request.
Please ensure tests pass, code follows style (ruff check), and you have signed the CLA.
See our Contributing Guide.

@github-actions github-actions Bot added tests size/L Large PR (< 500 lines) labels Jul 4, 2026
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
🤖 AI Agent: contributor-guide — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Welcome, and thank you for your contribution! 🎉

Your detailed explanation of the issue and the targeted scope of changes is excellent. The regression test addition is also well-done.

Before merging, please ensure:

  1. Update the documentation to reflect the changes in resolve() and wait_for_quorum() behavior.
  2. Verify that the Microsoft CLA is signed.

For guidance, check out CONTRIBUTING.md.

@SemTiOne

SemTiOne commented Jul 5, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

…st vote

resolve() woke on the first quorum vote's event and ran the quorum
check exactly once, falling through to default_action before later
votes could arrive -- an M-of-N quorum could never be satisfied once
M > 1. wait_for_quorum() now loops: it re-checks quorum after every
vote and re-arms the event (inside the same lock approve()/deny()
hold before calling set()) so a vote landing in that window is never
lost.

approve()/deny() previously only fired the wakeup event on the vote
that first set req.decision; later votes were still recorded but
never woke a blocked resolve(). Left as-is, a partial fix would still
land on the correct decision but only after blocking for the entire
timeout, defeating the point of the fix. Both now fire on every
accepted vote.

Fixes microsoft#3186

Signed-off-by: SemTiOne <emphyst80@gmail.com>
@SemTiOne
SemTiOne force-pushed the fix/escalation-quorum-wait-timeout branch from 69c742b to 96548b1 Compare July 5, 2026 08:08
@github-actions github-actions Bot added size/M Medium PR (< 200 lines) and removed size/L Large PR (< 500 lines) labels Jul 5, 2026
@SemTiOne
SemTiOne marked this pull request as ready for review July 5, 2026 08:40
@rpelevin

rpelevin commented Jul 6, 2026

Copy link
Copy Markdown

One extra regression I would want before this lands is the no-effect boundary around partial quorum.

After the first allow vote, and before the quorum predicate is satisfied, no policy decision or action path should be able to observe allow, default allow, or a completed approval. The intermediate vote is evidence, not authorization. Authorization exists only after the action-bound quorum predicate closes.

I would cover three assertions:

  1. one allow vote in a multi-approver chain keeps the request pending and produces no executable approval;
  2. timeout before quorum resolves to deny with an auditable timeout reason, request id, required quorum, and observed vote count;
  3. a later quorum-satisfying vote wakes the waiter immediately rather than relying on the timeout re-check.

That keeps the invariant crisp: require_approval is fail-closed and no-effect until the retained approval evidence proves the required quorum for the bound action.

@SemTiOne

SemTiOne commented Jul 6, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback @rpelevin.

Yeah, I verified this against main before this PR's changes: req.decision and list_pending() both reflect a premature ALLOW/not-pending state after a single vote when quorum requires more than one. Pre-existing from #3126/#3127, not introduced or touched by this fix (scope here is resolve()/wait_for_quorum() timing only). I agree this needs its own fix rather than folding into this PR. Happy to open a follow-up issue for the quorum-aware req.decision/list_pending() gap if that's useful.

Assertion 3 is already covered by TestQuorumWaitsFullTimeout.

@rpelevin

rpelevin commented Jul 6, 2026

Copy link
Copy Markdown

Yes, a follow-up issue would be useful.

I would scope it to one observable invariant: an incomplete quorum can be retained as evidence, but it must not make the request look authorized or remove it from pending work.

The issue I would file is:

  1. before quorum, req.decision is pending or unset, not ALLOW;
  2. list_pending() still includes the request with observed_vote_count and required_quorum;
  3. timeout before quorum creates a terminal deny/timeout record with request id, observed votes, required quorum, and reason;
  4. once quorum is satisfied, the terminal allow is created once for the bound action and pending clears.

That keeps this separate from the waiter timing fix in this pull request: the current fix makes the wait path wake correctly; the follow-up makes the observable decision state fail closed until quorum is actually satisfied.

imran-siddique
imran-siddique previously approved these changes Jul 7, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the quorum fix in integrations/escalation.py and the regression test.

The core change is correct. resolve() now routes quorum through the new wait_for_quorum(), which loops re-checking _quorum_outcome() after each vote and re-arms the per-request Event with clear() while holding self._lock — the same lock approve()/deny() mutate the request under before they call set() outside it. That ordering means there is no lost wakeup and no deadlock: if a vote's set() lands between the unlock and the event.wait(), wait() returns immediately because the event is already set, and the loop re-evaluates. The companion fix to fire the event on every accepted vote (not just the one that first sets req.decision) is what makes the second-vote wakeup prompt instead of blocking to the deadline.

_quorum_outcome() as the single source of truth for both the wait predicate and the final decision (denials checked before approvals) means the waited result and the re-derived decision can't disagree, and the zero-vote path still leaves req.decision == PENDING and falls through to default_action correctly. The documented caveat that non-in-memory backends still poll once is accurate and reasonable.

The regression test is strong: it asserts both the ALLOW outcome and elapsed < 1.0s (votes at 0.1s/0.4s against a 2s timeout), so it discriminates the event-firing fix from a naive implementation that would merely block to the timeout and re-check. CI green. LGTM.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: security-scanner — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

No security issues found.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • wait_for_quorum() in agent_os/integrations/escalation.py -- missing docstring
  • resolve() in agent_os/integrations/escalation.py -- docstring outdated; does not reflect new behavior of waiting for all quorum votes within the timeout
  • README.md -- no evidence of updates to reflect changes in quorum behavior
  • CHANGELOG.md -- missing entry for changes to quorum behavior in resolve() and wait_for_quorum()

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High resolve() behavior modified to wait for the full quorum timeout instead of resolving on the first vote. Existing code relying on immediate resolution after the first vote may now experience delays, potentially altering workflows.
High approve() and deny() now fire wake-up events on every accepted vote, not just the first. Code depending on the previous behavior of firing events only on the first vote may encounter unexpected behavior or performance changes.
Medium Introduction of wait_for_quorum() method in InMemoryApprovalQueue. This new method changes how quorum satisfaction is checked, potentially impacting custom integrations or subclasses relying on the previous quorum-checking logic.
Medium _quorum_outcome() introduced as the single source of truth for quorum evaluation. Custom implementations or overrides of quorum evaluation logic may need to adapt to this centralized approach.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `agent-os/src/agent_os/integrations/escalation.py`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

agent-os/src/agent_os/integrations/escalation.py

  • test_wait_for_quorum_handles_no_votes -- Validate wait_for_quorum correctly handles cases where no votes are cast within the timeout.
  • test_wait_for_quorum_handles_partial_quorum -- Ensure wait_for_quorum exits correctly when only partial quorum is achieved before timeout.
  • test_quorum_outcome_handles_edge_cases -- Test _quorum_outcome with edge cases like zero required approvals or denials.
  • test_resolve_handles_unexpected_request_id -- Verify resolve handles cases where the request_id does not exist.

agent-os/tests/test_escalation.py

  • test_resolve_handles_no_votes -- Confirm resolve applies the default_action when no votes are cast within the timeout.
  • test_resolve_handles_partial_votes -- Ensure resolve correctly applies default_action when quorum is not met but some votes are cast.
  • test_resolve_with_non_in_memory_backend -- Validate resolve behavior when using a non-in-memory backend.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 0 warnings. The changes improve quorum handling and fix the bug without introducing security or correctness issues.

# Sev Issue Where

No action items required. Clean change.

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

Spell-check changed files fails on wakeup (3 hits, all in lines this PR adds: a code comment and a test assertion message). Add wakeup to .cspell-repo-terms.txt or reword to wake-up.

Signed-off-by: SemTiOne <emphyst80@gmail.com>
@SemTiOne

SemTiOne commented Jul 8, 2026

Copy link
Copy Markdown
Author

Fixed. Reworded to wake-up.

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

The loop re-checks the quorum outcome after each vote and re-arms the event under the same lock approve/deny mutate, so an M-of-N quorum gets its full window and denies on timeout via the DENY default. The spelling finding is addressed and CI is green; the outstanding review looks stale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR (< 200 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(escalation): resolve() wakes on first vote without waiting for quorum timeout

4 participants