Skip to content

Reply controller cannot record rejected duplicate batches or recover pass-2 validation #35

Closed

Description

@dkropachev

Problem

The PR reply controller can refuse to record an independent validator's rejected verdict because the rejected draft contains a duplicate reply. This leaves the controller with no active rejection, so it then refuses the permitted corrected pass-2 batch. The workflow cannot recover without bypassing its required rejection history.

Observed with dk-workflows 1.1.19 on Codex/Linux, while iterating scylladb/python-driver#1146. The rejection/registration failure was retained from the previous run; a subsequent run verified the same head and reconciled the controller but could not recover the missing rejection. The branch was already fixed, 436 focused tests passed with 11 expected failures, and all 34 CI checks passed. The remaining minor thread still lacked acknowledgement and resolution, blocking workflow completion.

The same failure was reproduced offline against current main, version 1.1.20, at commit 38317beb335654efc2ab4008ecd2470637525aef, using the repository's real controller and existing mocked GitHub fixture. The full PR workflow was not rerun against 1.1.20.

Observed sequence

  1. A reply batch proposed the Major explanation again, although that exact answer was already published at discussion_r4369585171. The batch also included the remaining Minor acknowledgement.

  2. The independent validator returned status: rejected, pass: 1, bound to the original batch digest. Its correction was to retain the verified Major answer as a skipped mapping and prepare only the remaining Minor reply against fresh evidence.

  3. Submitting that rejected batch and verdict to reply_passes.py publish hits the duplicate-answer guard before verdict recording:

    request already has a verified answer; retain its mapping and skip it
    
  4. The rejection is absent from controller history. The retained state has active: null, blocked: null, and phase inline, despite the externally retained pass-1 rejection.

  5. reconcile succeeds in verifying existing replies, but cannot import or adopt the missing rejection.

  6. A corrected, independently approved pass-2 batch then fails because there is no recorded active rejection:

    first verdict must be pass 1
    

Submitting the correction as a fresh pass 1 would discard the required correction history. Editing the original rejected batch would invalidate its bound digest. The documented controller exposes only next, publish, reconcile, and resolve; no rejection-registration recovery operation is available. No duplicate reply, state reset, helper modification, or direct GitHub mutation was used to work around the failure.

Why recovery fails

Controller.publish() checks for already answered requests and requires the latest snapshot before reading the independent verdict. The duplicate check raises immediately, so even a rejected verdict never reaches persistence:

def publish(self, batch_path, verdict_path=None):
require(self.work['agent'] == self.agent, 'reuse the reply agent, or reconcile on resume')
require(not self.work['blocked'], 'reconcile the blocked workflow before publication')
require(self.work['observations'], 'run next before preparing a batch')
observation = self.work['observations'][-1]
require(self.work['phase'] == observation['phase'] and self.work['phase'] != 'done',
'run next for the current phase')
batch = guard.read(batch_path)
expected = guard.validate(batch, require_details=True)
source_map = all_sources(expected)
require(batch['snapshot'].get('selection') == guard.read(observation['snapshot']).get('selection'),
'batch changed selected review')
allowed = self.selected_sources(expected)
require(all(r['source'] in allowed and (not r.get('threadReference') or
r['threadReference'] in {t['id'] for t in self.selected_threads(expected)})
for r in batch['requests']), 'request outside selected review')
for request in batch['requests']:
answer = self.work.get('answers', {}).get(guard.digest([request['source'], request['excerpt']]))
if (request['disposition'] == 'reply' and answer and answer['status'] == 'current' and
answer['sourceFingerprint'] == fingerprint(source_map[request['source']])):
find_receipt(expected, answer['receipt'])
raise guard.GuardError('request already has a verified answer; retain its mapping and skip it')
require(batch['snapshot'] == guard.read(observation['snapshot']) and
batch['conversation'] == guard.read(observation['conversation']),
'batch must use the latest controller snapshots')

Pass-2 admission requires an existing rejected entry. Creating that entry, copying the batch/verdict, and saving the rejection all happen after the publication-oriented checks:

verdict = guard.read(verdict_path) if verdict_path else None
stale_requests = {s['answerKey'] for s in self.stale_answers(expected) if s['requestPresent']}
held = {answer_state.key(guard, r) for r in batch['requests'] if r['disposition'] == 'held'}
needs_verdict = bool(batch['replies'] or stale_requests - held)
if needs_verdict:
require(verdict is not None, 'independent verdict required for replies or stale request reassessment')
if verdict is not None:
require(verdict.get('batchSha256') == guard.digest(batch) and
verdict.get('status') in ('approved', 'rejected') and
isinstance(verdict.get('validatorAgent'), str) and verdict['validatorAgent'].strip() and
verdict['validatorAgent'] != self.agent, 'invalid or non-independent verdict')
if verdict is not None and verdict.get('status') == 'approved':
guard.require_approval(batch, verdict)
active = self.work['active']
if active is None:
require(verdict is None or verdict.get('pass') == 1, 'first verdict must be pass 1')
entry = {'phase': self.work['phase'], 'head': expected['head'], 'status': 'prepared',
'attempts': [], 'receipts': []}
self.work['history'].append(entry)
self.work['active'] = len(self.work['history']) - 1
else:
entry = self.work['history'][active]
previous = entry['attempts'][-1]['verdict']
require(entry['status'] == 'rejected' and previous['pass'] == 1 and verdict is not None and
verdict.get('pass') == 2 and verdict.get('validatorAgent') == previous['validatorAgent'],
'one correction using the same validator is permitted')
folder = Path(tempfile.mkdtemp(
prefix=f"batch-{len(self.work['history'])}-pass-{len(entry['attempts']) + 1}-", dir=self.directory))
saved_batch, saved_verdict, journal = folder / 'batch.json', folder / 'verdict.json', folder / 'journal.json'
guard.write(saved_batch, batch)
if verdict is not None:
guard.write(saved_verdict, verdict)
entry['attempts'].append({'batch': str(saved_batch), 'batchSha256': guard.digest(batch),
'verdictPath': str(saved_verdict) if verdict else None,
'verdict': verdict, 'journal': str(journal) if batch['replies'] else None})
self.save()
if verdict is not None and verdict['status'] == 'rejected':
entry['status'] = 'rejected'
self.save()
if verdict['pass'] == 2:
return self.stop('rejected', 'final validator rejection; no further reply publication')
return {'action': 'correct_batch', 'corrections': verdict.get('corrections', []),
'stale_answers': self.stale_answers(expected)}
entry['status'] = 'in_flight'

reconcile() preserves a rejection only when an active entry was already recorded; it has no path for the externally retained verdict rejected before persistence:

def reconcile(self):
require(not self.work['blocked'] or self.work['blocked']['kind'] != 'rejected',
'final rejection cannot be reset by reconciliation or restart')
snapshot, conversation, evidence = self.live()
resolution.reconcile(self, evidence)
active = self.work['active']
if active is not None:
entry = self.work['history'][active]
if entry['status'] == 'rejected':
require(evidence['head'] == self.work['head'],
'head changed with an outstanding rejection; do not reset the rejection limit')
self.work.update(agent=self.agent, blocked=None)
self.save()
return {'action': 'correct_batch', 'instructions': 'Run next for fresh evidence, then '
'correct the active batch with the same validator for pass 2. Rejection history is retained.',

The existing resume test covers a rejection that successfully reached persistence, rather than a rejected draft stopped before recording:

def test_resume_keeps_first_rejection_and_requires_same_validator_pass_two(self):
inline = self.inline_batch(self.next())
self.publish(inline, status='rejected')
self.assertEqual(self.controller('resumed-agent').reconcile()['action'], 'correct_batch')
step = self.controller('resumed-agent').next()
self.assertEqual(step['action'], 'correct_batch')
path, verdict = self.folder / 'corrected.json', self.folder / 'corrected-verdict.json'
guard.write(path, inline)
guard.write(verdict, dict(batchSha256=guard.digest(inline), validatorAgent='validator',
status='approved', corrections=[], **{'pass': 1}))
with self.assertRaisesRegex(guard.GuardError, 'same validator'):
self.controller('resumed-agent').publish(path, verdict)
self.assertEqual(self.github.posts, [])
def test_controller_rejects_approval_from_reply_agent(self):
inline = self.inline_batch(self.next())

Offline reproduction

A local reproducer uses ReplyPassesTest and its mocked GitHub API, with no network calls or real PR mutations:

  1. Publish the fixture's first inline answer successfully.
  2. Add another actionable inline request and reconcile to fresh inline evidence.
  3. Prepare a batch containing both the already answered request and the new request. Submit a valid independent rejected pass-1 verdict.
  4. Assert that the duplicate guard raises, history remains unchanged, and active is still null.
  5. Reconcile, prepare a corrected batch that skips the existing answer and replies only to the new request, and submit an approved pass-2 verdict from the same validator.
  6. Assert the first verdict must be pass 1 failure and no additional mocked publication.

Observed output:

{
  "rejected_batch_registration_error": "request already has a verified answer; retain its mapping and skip it",
  "rejection_recorded": false,
  "reconcile_recovers_missing_rejection": false,
  "corrected_batch_pass_2_error": "first verdict must be pass 1",
  "history_entries_before_and_after": [1, 1],
  "active_after_registration": null,
  "mock_posts_before_and_after_failed_attempts": [1, 1],
  "network_calls": 0
}

Expected behavior

  • Retain a valid independent rejection and its original batch/verdict identity even when the draft fails a publication-specific guard. Rejection recording must not publish or authorize an invalid draft.
  • Provide a supported, auditable recovery path for the already retained external pass-1 rejection, preserving its original batch digest, validator identity, evidence, and correction count.
  • Permit exactly the existing same-validator pass-2 correction against fresh evidence, including corrections that remove all replies. Reconciliation and restart must preserve the first rejection and any final rejection.
  • Continue to reject duplicate publication, stale evidence, wrong PR/selection/head, malformed or non-independent verdicts, wrong-validator corrections, extra attempts, and uncertain publication replay.
  • Add focused regression coverage for rejected duplicate batches before persistence, snapshot-freshness failures during rejection submission, recovery after restart, corrected pass 2 without duplicate publication, and final-rejection enforcement.

A possible implementation is to separate validated rejection recording from approved-batch publication checks, or add an explicit guarded adoption operation. Simply skipping duplicate checks or restarting validation at pass 1 would weaken the existing guarantees.

Related: #9 addresses edited semantic-answer evidence; #30 addresses post-reply review-scope provenance. This report concerns rejection persistence before either recovery or publication.

Activity

  1. added a commit that references this issue on Oct 25, 2026
    5da1f6f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions