Skip to content

feat(agents): issue-702-fix-devloop-a-failed-approval-must-not-p - #709

Merged
mctl-agents[bot] merged 2 commits into
mainfrom
feat/agents-issue-702-fix-devloop-a-failed-approval-must-not-p
Oct 18, 2026
Merged

mctl-agents[bot] merged 2 commits into
mainfrom
feat/agents-issue-702-fix-devloop-a-failed-approval-must-not-p

Conversation

@mctl-agents

@mctl-agents mctl-agents Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Implements accepted proposal mctl-agents/issue-702-fix-devloop-a-failed-approval-must-not-p.

Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-agents/proposals/issue-702-fix-devloop-a-failed-approval-must-not-p/

Generated by mctl-agents Tier 2 implementer. Review carefully — this PR was opened automatically and the spec may have gaps.

Closes #702

A failed or refused canonical approval now ends the own we_ Failed, returns a
typed DevLoopResult.outcome (approval_refused / approval_failed) with prefixed
ended, re-asks a fresh grant on the same we_ when the grant is provably stale,
and is restartable on its own workflow id. Gated by the approval-failure-outcome
patch.

Proposal: platform-gitops/agents-state/mctl-agents/proposals/issue-702-fix-devloop-a-failed-approval-must-not-p/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Claude finished @mctl-agents[bot]'s task in 6m 26s —— View job


Review of PR #709 complete

  • Gather context (diff, CLAUDE.md, AGENTS.md)
  • Analyze orchestrator/temporal/cli.py changes
  • Analyze orchestrator/temporal/start.py changes
  • Analyze orchestrator/temporal/workflows/dev_loop.py changes
  • Analyze tests/test_temporal_cli.py changes
  • Post inline findings
  • Submit final verdict

Verdict: Changes requested (2 P2 findings, posted inline, bound to commit 776050f).

P2 — stale _approve_refusal_code can suppress the stale-grant retry. _delegated_approve's two early-return paths (slug mismatch, missing grant) never reset self._approve_refusal_code. The new retry loop reads that attribute right after a failed flip to decide whether to supersede-and-reask vs. end the loop permanently. Because it's not reset on those two paths, the loop can read a leftover code from a previous attempt instead of the current refusal reason — meaning the clearest staleness signal there is ("the proposal changed since the approval was requested") can be misclassified and the loop can end prematurely instead of retrying, which undercuts exactly what #701/#702 are meant to fix. See orchestrator/temporal/workflows/dev_loop.py:2295-2298 vs the retry decision at :4008-4012.

P2 — no test coverage for the PR's core behavior change. The typed approval_refused/approval_failed outcomes, the new DevLoopResult.outcome field, _approval_ended, the APPROVAL_FAILURE_OUTCOME_PATCH gate, and the ~120-line stale-grant retry loop in dev_loop.py have no new/updated test in tests/test_dev_loop_workflow.py. Only tests/test_temporal_cli.py was touched, which covers restart_if_restartable (the start.py restart-eligibility side), not the workflow logic that actually produces these outcomes.

Both are explained in detail in the inline comments on the diff.

Note: an early permission-probing attempt accidentally posted a placeholder comment with body "test" on dev_loop.py:2317 (I don't have delete/edit permission to remove it) — please disregard that one; the real finding is the follow-up comment at the same line.

Nothing else stood out: the start.py/cli.py rename (restart_if_abandoned → restart_if_restartable) and RESTARTABLE_ENDED_PREFIXES widening are correctly threaded through, and tests/test_temporal_cli.py's new cases for the "approval refused/failed" restart-eligible endings look correct.

I was unable to run the test suite or a linter directly (no execution permission in this run), so this review is based on static reading of the diff and surrounding code only.

self._approve_refusal_code = ""
return None, f"approve flip unavailable: {type(exc.cause).__name__ if exc.cause else 'unknown'}"
if delegated.refusal or delegated.result is None:
self._approve_refusal_code = delegated.code or ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-high · commit 776050f.

Findings

Finding 1

  • Severity: P3
  • File: orchestrator/temporal/workflows/dev_loop.py (approx. line 3970)
  • Anchor: + typed = workflow.patched(APPROVAL_FAILURE_OUTCOME_PATCH)
  • Concrete failure scenario: The diff adds significant workflow-level logic to DevLoopWorkflow—including version patching via APPROVAL_FAILURE_OUTCOME_PATCH, the while True loop to re-ask reapproval on stale/drifted grants (STALE_GRANT_REFUSAL_CODES), capping supersede retries, and setting terminal failure outcomes via _approval_ended. While unit tests for CLI restart handling and prefix filtering were updated in tests/test_temporal_cli.py, no test visible in this diff covers these workflow-level state transitions, retry loops, or replay determinism under the patch marker.
  • Proposed fix: Add workflow tests in the Temporal workflow test suite exercising the approval stage under both patched and unpatched histories (verifying replay determinism, stale grant superseding loop, supersede capping, and terminal outcome generation).

self._approve_refusal_code = ""
return None, f"approve flip unavailable: {type(exc.cause).__name__ if exc.cause else 'unknown'}"
if delegated.refusal or delegated.result is None:
self._approve_refusal_code = delegated.code or ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: _delegated_approve's two early returns (slug mismatch at line 2295-2296, missing grant at line 2297-2298) never reset self._approve_refusal_code. The new stale-grant retry loop (around line 4008) reads that attribute right after a failed _delegated_approve call to decide whether to supersede-and-reask vs. end the loop. When the call takes one of those two early-return paths, code still holds whatever a previous attempt left (or "" on the first try), so maybe_stale is computed from stale/unrelated data instead of the current refusal reason.

Concrete failure: the clearest stale-grant signal there is -- approve flip refused: the proposal changed since the approval was requested (reachable because _request_canonical_approval re-looks-up the slug and can get a different one than the outer slug closure variable captured once before the retry loop) -- is not itself classified via code. If the previous iteration's _approve_refusal_code happened not to contain digest/drift, maybe_stale comes out False and the loop permanently ends with approval_refused instead of superseding the grant and re-asking, even though this is exactly the staleness case #701/#702 exist to recover from.

Suggest setting self._approve_refusal_code on every return path of _delegated_approve (including the two early returns), or giving those two paths an explicit code (e.g. stale-slug/missing-grant) that STALE_GRANT_REFUSAL_CODES recognizes.

investigate_result,
approve_result,
human_input_outcome,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: this PR's core behavior change -- the typed approval_refused/approval_failed outcomes, the new DevLoopResult.outcome field, _approval_ended, the APPROVAL_FAILURE_OUTCOME_PATCH gate, and this whole stale-grant retry loop (lines ~3976-4039, plus the non-canonical path at line ~4056) -- has no new or updated test in tests/test_dev_loop_workflow.py. The only tests touched in this PR are in tests/test_temporal_cli.py, which cover restart_if_restartable/RESTARTABLE_ENDED_PREFIXES (the start.py side), not the workflow logic that actually produces those outcomes.

Given the PR title is literally "a failed approval must not proceed" and this ~120-line hunk is where that happens, I'd expect at least: (1) a canonical-loop test where the delegated approve flip fails/refuses and asserts result.outcome == "approval_failed"/"approval_refused" and result.ended starts with the right prefix with implement is None; (2) a test exercising the stale-grant retry path (refusal code containing digest/drift -> supersede -> re-ask -> succeed); and (3) a non-canonical-path test for the typed and not self._canonical branch at line 4056.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Has P1/P2 findings, changes requested: 2 P2. (1) dev_loop.py: _delegated_approve's two early-return paths (slug mismatch, missing grant) never reset self._approve_refusal_code, so the new stale-grant retry decision can read a stale/unrelated code and skip the supersede-and-reask path even for the clearest staleness signal ("the proposal changed since the approval was requested"), ending the loop prematurely. (2) No test coverage was added for this PR's core change: the typed approval outcomes, _approval_ended, and the stale-grant retry loop in dev_loop.py -- only test_temporal_cli.py (the start.py restart-eligibility side) was updated.

…failed-approval-must-not-p

Reset/assign _approve_refusal_code on the two early returns of
_delegated_approve (stale-slug counts as a stale-grant signal), add
workflow tests for typed approval outcomes, update the capped-supersede
test to the typed ended string, and fix two E501 lint errors.

Proposal: platform-gitops/agents-state/mctl-agents/proposals/issue-702-fix-devloop-a-failed-approval-must-not-p/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mctl-agents

mctl-agents Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Claude finished @mctl-agents[bot]'s task in 4m 11s —— View job


Review of PR #709 complete (commit e1863ce)

  • Gather context (diff against pinned SHAs, CLAUDE.md/AGENTS.md)
  • Analyze orchestrator/temporal/workflows/dev_loop.py changes since last review
  • Verify prior P1/P2 findings were addressed
  • Post inline findings (if any)
  • Submit final verdict

Verdict: Approved (0 P1/P2, 1 P3, bound to commit 776050f → e1863ce's fix-up).

This commit (e1863ce) addresses both P2 findings from the prior review of 776050f:

  1. Stale _approve_refusal_code fix — confirmed correct. _delegated_approve's two early-return paths (dev_loop.py:2295-2299) now set self._approve_refusal_code to "stale-slug" / "missing-grant" respectively, and "stale-slug" was added to STALE_GRANT_REFUSAL_CODES (dev_loop.py:1112). This means the slug-mismatch refusal — the clearest "the proposal changed since the approval was requested" signal — now correctly drives maybe_stale → supersede-and-reask instead of reading a leftover code from a previous iteration. I traced through the retry loop at dev_loop.py:4008-4014 and the fix closes the gap exactly as suggested.

  2. Test coverage — meaningfully improved, one gap remains (P3, non-blocking). New/updated tests now assert result.outcome and the "approval refused"/"approval failed" typed ended prefixes (tests/test_dev_loop_workflow.py:1957-1958, :8218-8219, and the new test_a_non_stale_refusal_ends_typed_approval_refused). What's still missing is a direct regression test for the retry-then-succeed path this fix enables (slug-mismatch → _approve_refusal_code = "stale-slug" → supersede → re-ask → fresh grant → flip succeeds) — posted as an inline P3 since it's the exact scenario the original bug was about, but not blocking since the underlying logic fix is verified correct by inspection.

No new issues introduced by this fix-up; start.py's docstring-only change is cosmetic.

I was unable to run the test suite or a linter directly (no execution permission in this run), so this review is based on static reading of the diff and surrounding code only.

assert result.outcome == "approval_refused"
assert result.ended.startswith("approval refused")
assert result.implement is None
assert len(delegated) == 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: both prior P2s look addressed by this commit — _delegated_approve's two early-return paths now set self._approve_refusal_code ("stale-slug" / "missing-grant"), and "stale-slug" was added to STALE_GRANT_REFUSAL_CODES, so the slug-mismatch refusal (the clearest staleness signal) now correctly drives the supersede-and-reask path instead of being misread from a leftover code. Good fix.

One gap remains in the new tests here: there's still no direct regression test that exercises the success path this fix enables — i.e. _delegated_approve hits the slug != self._approval_slug early return (code "stale-slug"), the loop calls _supersede_grant_if_stale, re-asks via _await_canonical_reapproval, and a fresh grant lets the flip succeed on retry. The two new/updated tests (test_a_non_stale_refusal_ends_typed_approval_refused, and the approval_refused/outcome assertions on the supersede-cap test) cover the refusal outcomes well, but not the retry-then-succeed path that was the actual bug. Not blocking given the logic fix itself is correct and covered indirectly, but worth adding before the next change touches this area.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (1 P3). Both prior P2s are fixed: _delegated_approve's two early-return paths now set _approve_refusal_code (stale-slug/missing-grant), and stale-slug was added to STALE_GRANT_REFUSAL_CODES, so the slug-mismatch staleness signal is correctly recognized by the retry decision instead of reading a leftover code. Test coverage was also meaningfully extended (typed outcome/ended assertions) though the retry-then-succeed path still lacks a direct test (noted as P3, non-blocking). Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-high · commit e1863ce.

No significant issues found.

@mctl-agents
mctl-agents Bot merged commit 56b9224 into main Oct 18, 2026
12 checks passed
@mctl-agents
mctl-agents Bot deleted the feat/agents-issue-702-fix-devloop-a-failed-approval-must-not-p branch October 18, 2026 23:29
mashkovd added a commit that referenced this pull request Oct 27, 2026
A present but non-mapping subject, binding, risk or sections block was
skipped instead of rejected, so `risk: high` silently waived the
required failure modes and `binding: null` passed with no binding.
Malformed is not absent: emit BAD_BLOCK.

run_explainer.sh recorded the latency of its own placeholder echo as a
measurement; it now writes unobserved. The #702 pre-implementation
commit is observable (a42c3e7, first parent of the #709 merge), so the
report names it instead of calling it unobserved.
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.

fix(devloop): a failed approval must not project success or completed

0 participants