Skip to content

feat(commands): semantic answer_decision action + meta.decision in the pending payload#6

Merged
plotarmordev merged 3 commits into
plotarmordev:mainfrom
jerryfane:feat/answer-decision
Jul 17, 2026
Merged

feat(commands): semantic answer_decision action + meta.decision in the pending payload#6
plotarmordev merged 3 commits into
plotarmordev:mainfrom
jerryfane:feat/answer-decision

Conversation

@jerryfane

Copy link
Copy Markdown
Contributor

Implements the validated alternative to #5's raw send_keys, per the contract discussion there: Tendwire — the only party that can observe the pane — owns both freshness validation and key calibration, and connectors speak in intent, not keystrokes.

What

  • meta.decision on pending interactions: {decision_ref, kind: single|multi|plan, prompt, options: [{ref, label}], multi_select, question_count}, strictly normalized from the ingested backend-pending records; decision_ref rotates whenever the prompt changes.
  • command.submit action answer_decision: {decision_ref, selection: {option_refs} | {text}}, plugged into the full existing mutation machinery — canonical mutations, selector proofs, request-id dedup/replay, dry-run, and the claim/reservation/uncertainty pipeline.
  • Fail-closed validation before any key is sent: fresh claim-time check that decision_ref is the worker's current pending decision, typed failures (decision_not_pending, unknown_worker, invalid_selection, unsupported_decision), multi-question prompts refused, write-ins only for single.
  • Calibration lives in backends/herdr_decision.py with every TUI assumption documented; no public raw send_keys action is exposed.
  • dry_run keeps the fail-safe default (omitted ⇒ dry-run) for this action like every other mutation; connectors send an explicit dry_run: false.

Verification

  • Suite: 2727 passed (the sqlite-sidecar benchmark file needs TENDWIRE_BENCHMARK_HERDRES_ROOT; with it, one environment-dependent case fails identically on main in our clean checkout, so it is not introduced here).
  • Paired cross-check against the herdres connector half (luminexord/herdres#152): herdres's live tuple-matrix pairing test now enumerates both actions against this branch's producer — decision statuses round-trip verbatim on answer_decision and fail closed on the send path; 656 passed on the herdres side.

Authored by a gpt-5.6-sol implementation agent from a coordinator spec; coordinator-reviewed. Happy to adjust the contract shape — the herdres half tracks this PR.

🤖 Generated with Claude Code

@jerryfane

Copy link
Copy Markdown
Contributor Author

Heads-up on the red CI: it wasn't this branch. luminexord/herdres@31c3152 (merged this morning) retuned TURN_LIST_PAGE_LIMIT 50 → 100, and this repo's sidecar benchmark hardcodes the expected turns --limit 50 command sequence while CI pairs against herdres@main — so every tendwire PR run after that commit fails test_tiny_installed_candidate_run_emits_complete_aggregate (tendwire main's last green run predates it).

Pushed a fix on this branch: the benchmark now derives the expected limit from the paired Herdres checkout (getattr(tendwire_client, "TURN_LIST_PAGE_LIMIT", 50)), so Herdres owns its own page size and this class of cross-repo break disappears. Verified locally: 7/7 benchmark tests pass paired against current herdres@main.

You'll probably want that one commit on main regardless of this PR's fate — happy to split it out into its own PR if you prefer.

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

I reviewed this together with the explicitly paired Herdres PR
luminexord/herdres#152. The focused Tendwire suites pass, and the complete
paired Herdres suite passes against this branch, but the following behavioral
gaps remain merge blockers because the current tests do not exercise them.

Required changes

1. Do not map a live competing claim to decision_not_pending

claim_backend_pending_decision() correctly distinguishes
already_claimed, but _validate_pending_decision() and
_claim_pending_decision() collapse every unmapped status to
decision_not_pending.

I reproduced this sequence:

claim request A: claimed
submit request B for the exact same decision: decision_not_pending / no_receipt
same decision_ref still present in pending.list: true
pane calls: 0

That response is factually incorrect. The paired Herdres handler interprets
decision_not_pending as authoritative stale state and retracts the Telegram
keyboard. If request A then fails or becomes uncertain, the user has lost the
active control even though the decision remains pending. The same collapse can
also interact badly with abandoned-reservation recovery near the independent
receipt/claim lease boundaries.

Required correction:

  • Preserve already_claimed as a distinct non-stale outcome.
  • Return a retryable/in-progress semantic that does not tell connectors the
    decision disappeared and does not cause a second pane mutation.
  • Do not terminalize an abandoned reserved request merely because another
    still-live claim exists.
  • Update the producer disposition matrix and paired Herdres validator together
    if a new public status is introduced.

Required regressions:

  1. Two different request IDs race for one decision; exactly one claim can send.
  2. The second request never returns decision_not_pending while the exact
    decision remains public.
  3. Cover first claimant success, safe pre-send failure, send uncertainty,
    process loss before send_started, and lease expiry/takeover.
  4. Prove the paired Telegram keyboard is not retracted for the competing-claim
    outcome.

2. Make the advertised multi contract real or remove it from this release

The paired production adapter currently emits multi-select and multi-question
AskUserQuestion prompts as pending_interaction, not pending_decision.
Running that real adapter output through this branch produced:

single AskUserQuestion -> open_prompt / decision_kind=single
multi AskUserQuestion  -> read_succeeded_invalid_prompt / no decision

The new multi tests construct a synthetic top-level pending_decision shape
that the paired producer does not emit. Consequently, the relative-cursor
Down/Enter calibration is not exercised by the real cross-repository path.

Choose one bounded correction:

  • Scope this release to the production-proven single and plan kinds and
    reject multi fail-closed throughout Tendwire and Herdres; or
  • Change the paired adapter/parser contract so a production-shaped multi
    decision reaches Tendwire, then prove its exact key calibration with a real
    paired fixture. The test must begin with the adapter's actual tool input, not
    a hand-built Tendwire-only object.

Do not retain an unreachable public multi promise for future use.

3. Reject option truncation before deriving key ordinals

_pending_observation_from_turn() silently keeps only the first 12 choices,
then the write-in calibration derives option_count + 1 from that shortened
list. A reproduced input containing 14 ordinary options plus a custom row at
ordinal 15 was accepted as a 12-option decision and generated key ordinal 13.
That can select the wrong row.

The currently paired adapter happens to cap its generated list, but this
boundary is private/untrusted input and must fail closed rather than silently
changing terminal semantics.

Required correction and proof:

  • Reject a decision whose source option count exceeds the supported bound, or
    retain and validate the exact source ordinal separately from displayed
    options.
  • Add boundary tests at the maximum, maximum + 1, and with the custom row just
    beyond the retained boundary.
  • Prove no pane operation occurs for an unsupported/truncated shape.

4. Complete the paired public contract gate

The linked Herdres response validator currently accepts malformed bare
responses such as {"ok": true, "status": "accepted"}. That is tracked on
luminexord/herdres#152, but this PR must not be merged independently because
the feature is unsafe until the consumer requires the exact schema-v2 envelope,
correlation fields, disposition, and decision result.

Before re-review, please also document the new meta.decision payload and
answer_decision request/response contract, including supported kinds,
fail-closed behavior, dry-run default, retry dispositions, and the paired
version requirement.

Re-review gate

  • Focused decision, pending, command-replay, pre-send, CLI/daemon, and public
    safety suites.
  • Deterministic concurrency and response-loss tests described above.
  • Production-shaped adapter-to-Tendwire-to-Herdres fixtures for every supported
    kind.
  • Complete Tendwire and paired Herdres suites, compilation, and diff checks.
  • No merge or deployment of either half until both reviews are clean.

Micaxes added a commit to Micaxes/herdres that referenced this pull request Jul 16, 2026
Surface a blocked Claude pane's AskUserQuestion / ExitPlanMode prompt into the
connector's topic as a native reply keyboard, and answer it remotely — WITHOUT
the connector ever touching Herdr, and WITHOUT it encoding any key sequence.

The connector renders the prompt Tendwire carries in its pending payload
(meta.decision), maps a tapped/typed answer to a SEMANTIC selection, and submits
a neutral `answer_decision` command back to Tendwire:
  {action: "answer_decision", target: {worker_id},
   params: {decision_ref, selection: {option_refs:[...]} | {text}}}
Tendwire owns all Herdr access: it resolves worker_id -> pane, confirms the
prompt is still displayed (freshness), owns the per-Claude-Code TUI calibration,
and replays it. This matches the tendwire answer_decision contract in
plotarmordev/tendwire#6. Both neutrality guard tests pass unchanged, and the
connector no longer contains any calibration.

Rebased onto the 0.7.0rc train and reworked per review (luminexord#150):

- Contract altitude (was raw send_keys steps): decisions.py is now a pure state
  machine that emits a semantic selection; all TUI calibration moved to Tendwire.
- Request identity: the answer reuses the inbound message's HMAC-derived
  request_id and the durable ingress-command path (tendwire_client +
  ingress_requests both gain a validated `answer_decision` action), so it is
  idempotent and can no longer be rejected by validate_request_id.
- False success: a rejected submit (incl. Tendwire's typed decision_not_pending /
  invalid_selection / unknown_worker / unsupported_decision statuses) keeps the
  decision active + keyboard and replies with an explicit failure; only a
  terminal_accepted disposition confirms.
- Freshness: the answer path re-polls pending and refuses to submit for a stale
  decision_ref (already answered / superseded); Tendwire fails closed too.
- Multi-question AskUserQuestion (question_count > 1) fails closed to the
  read-only attention notice.
- Duplicate option labels are disambiguated so a reply-keyboard tap resolves to
  exactly one option ref; rc-train drops Telegram callbacks, so reply keyboards.
- /send keeps its queue-for-turn-boundary semantics and falls through instead of
  being hijacked into the decision wizard.
- Sync round-trip race: the delivery loop reloads the decisions bucket under the
  held lock before its save, and exposes a public active_items() iterator.
- HERDRES_REMOTE_DECISIONS moved to config.py with the standard flag idiom (empty
  value keeps the default) and documented in .env.example.
- tests/test_remote_decisions.py rewritten for the semantic contract: selection
  shapes, gateway submit success/failure semantics, freshness/staleness,
  multi-question fail-closed, /send fall-through, duplicate-label disambiguation,
  and rejection-status recognition.

Pairs with plotarmordev/tendwire#6 (semantic answer_decision + meta.decision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jerryfane and others added 2 commits July 16, 2026 21:19
…ey maps

Probed key-by-key on a real Claude Code 2.1.211 pane:
- single/plan: the digit alone selects AND submits — the previous trailing
  Enter would leak into whatever UI appears next (composer or a modal).
- write-in: the Type-something row ignores digits; it is reached with
  Down x N and the focused row is itself the text input.
- multi-select: digits toggle rows ABSOLUTELY (cursor-independent), Right
  reaches the Submit tab, Enter submits — replacing the fragile relative
  Down/Up + Space navigation entirely.
- option bound tightened 11 -> 9: every driven ordinal must stay a single
  keystroke; larger decisions fail closed to the read-only interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jerryfane

Copy link
Copy Markdown
Contributor Author

Round 2 pushed — all four blockers addressed. Ready for re-review.

1. Competing claims: already_claimed now maps to a new public status answer_in_progress (ok=false, dispositions no_receipt/in_progress only) at both validate- and claim-time; abandoned reserved requests are no longer terminalized while a live claim exists. Your reproduction sequence now yields answer_in_progress with zero pane calls, and the paired herdres handler keeps the keyboard and tells the user to re-tap (never retracts). Deterministic regressions cover: the two-request race (exactly one sends), loser-never-sees-decision_not_pending-while-public, winner pre-send failure releasing the claim for a loser retry, send-uncertainty durability, process loss before send_started, and lease expiry/takeover.

2. Multi is now real — and the calibration is live-verified, not assumed. The paired herdres adapter (companion PR luminexord/herdres#153) emits production-shaped single-question multi-select decisions; the end-to-end fixture here starts from the actual AskUserQuestion tool_input. I then probed every mapping key-by-key on a live Claude Code 2.1.211 pane, which falsified both prior calibrations (mine and the provisional one):

  • multi-select digits toggle rows absolutely (cursor-independent) — the fragile relative Down/Up+Space navigation is gone; steps are now digit… → Right (Submit tab) → Enter, verified end-to-end (→ Mushrooms);
  • single/plan digits select and submit instantly — the previous trailing Enter would have leaked into whatever UI appears next, so it no longer exists;
  • the write-in row ignores digits — it's reached with Down × N and the focused row is itself the text input (verified: "You picked turquoise actually").
    Multi-question prompts remain fail-closed (unsupported_decision).

3. Truncation: PENDING_DECISION_MAX_OPTIONS = 9 — every driven ordinal must stay a single keystroke, so over-bound decisions fail closed to the read-only interaction (no meta.decision, no pane ops, boundary tests at 9/10 incl. the custom row). Nothing truncates.

4. Contract: the herdres validator now requires the exact schema-v2 envelope (correlation echoes, dispositions, per-action result shape — see #153); docs/answer_decision.md documents the payload, request/response contract, supported kinds, fail-closed rules, dry-run default (omitted ⇒ dry-run; live requires explicit dry_run: false), retry semantics, and the paired-version requirement.

Paired verification: tendwire 2752 passed (+ sidecar benchmark 7/7 against the herdres branch); herdres 673 passed against this branch. CI should be green — the branch also still carries the paired-benchmark page-size fix.

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

Accepted after paired correction. The branch now preserves retryable competing claims, supports production multi-select, binds decision handles to the exact private source, accepts nine selectable single-choice rows plus the trailing write-in row without exposing a two-digit ordinal, fails closed above that bound, and documents the implemented limit. Verified locally against current main: 2,763 passed, 1 skipped; paired Herdres suite: 677 passed.

@plotarmordev
plotarmordev merged commit 33fa90a into plotarmordev:main Jul 17, 2026
1 check passed
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