Skip to content

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

Open
SemTiOne wants to merge 10 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 10 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 needs update to reflect changes in behavior
  • README.md -- section on escalation handling needs update to describe the new wait_for_quorum() method and changes to resolve()
  • CHANGELOG.md -- missing entry for behavioral changes in resolve() and the addition of 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 Modified behavior of EscalationHandler.resolve() to wait for the full quorum timeout and re-check quorum after every vote. Existing clients relying on the previous behavior of resolving on the first vote may experience unexpected delays or behavior changes.
High Modified behavior of InMemoryApprovalQueue.approve() and deny() to fire events on every accepted vote, not just the first. Existing clients relying on the previous behavior of events firing only on the first vote may experience unexpected behavior.
High Added wait_for_quorum() method to InMemoryApprovalQueue. If any subclasses of InMemoryApprovalQueue exist, they may need to implement this method to maintain compatibility.
High Changed EscalationHandler.resolve() to use wait_for_quorum() when quorum is configured. This changes the internal logic of resolve() and may affect its behavior for clients using quorum configurations.

@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_resolve_waits_for_a_late_second_vote_instead_of_defaulting_early -- Ensure resolve() waits for additional votes within the timeout period instead of defaulting prematurely.

Test coverage looks good. No additional gaps identified.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — Action Items:

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

TL;DR: 0 blockers, 1 warning. The PR addresses a critical bug in quorum handling, but the new wait_for_quorum method lacks a timeout test for the None case.

# Sev Issue Where
1 Warn wait_for_quorum lacks a test for timeout=None behavior. test_escalation.py

Action Items:

  1. Add a test case for wait_for_quorum to verify behavior when timeout=None. Fine as a follow-up PR.

@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