Skip to content

Goal action errors must carry a concrete next step (recovery affordance), not just a refusal#1791

Draft
interactor-pm-service[bot] wants to merge 6 commits into
mainfrom
goal/goal-action-errors-must-carry-a-concrete-next-step-recovery-
Draft

Goal action errors must carry a concrete next step (recovery affordance), not just a refusal#1791
interactor-pm-service[bot] wants to merge 6 commits into
mainfrom
goal/goal-action-errors-must-carry-a-concrete-next-step-recovery-

Conversation

@interactor-pm-service

Copy link
Copy Markdown
Contributor

Integration PR for this goal — its tasks commit onto this branch (one PR per goal). Opened automatically by the engine on first push; left as a draft until the goal completes.

psdjungpulzze and others added 6 commits July 4, 2026 06:13
…er (G#..., foundation)

Give every engine goal-action refusal a machine-readable, render-ready structure so
the UI shows plain-language guidance + a concrete next action without string-matching
the human message (G#371 binding rules).

- New PURE module src/lib/engine/goal-action-refusal.ts: GoalActionRefusal type
  (kind/title/message/recovery[]/resolvesAt?/actor?/occurredAt?) + GoalRecoveryAction,
  modeled on machine-blocking-reason.ts's BlockingReason. One dependency-free builder
  per kind (already_terminal, deploy_gate_failed, github_unverifiable/rate_limited,
  open_pause, no_credential/no_connection, merge_conflict, pr_closed/pr_draft/pr_not_ready).
- Carry it: optional readonly `refusal?: GoalActionRefusal` on EngineTransitionError,
  EngineValidationError, EngineDependencyError constructors (mirrors how
  InitiativeCollisionError carries initiative/drivers). Param is optional — no existing
  call site breaks. Coexists with the flat code/detail contract.
- Mapper: engineErrorToHttp surfaces `refusal` in the 409/422/502 bodies when present,
  via refusalFields; EngineHttpError.body gains `refusal?`. error/message unchanged
  (backward compatible).
- Unit tests for the builders (two-binding-rules well-formedness) and the mapper.

Scope: type + carrier + mapper + tests only — throw sites (task 2) and UI (task 3) untouched.
No agent-visible tools/data/workflows change, so no PM Agent config update is needed
(AI Agent Maintenance rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…row site (G#371, task 2)

Attach a fully-populated, render-ready `GoalActionRefusal` (task 1's type/builders) to
every goal-action refusal in goal-review.ts, honoring the two BINDING amendment rules:
NON-TECHNICAL copy (never a raw `GET pull #NNNN failed: 403`) and ALWAYS a concrete next
action. The refusal rides the thrown EngineTransition/Validation/Dependency error and is
surfaced verbatim by `engineErrorToHttp` (task 1) — routes already delegate to it, so no
v1 (daemon) / v2 (browser) route change is needed.

Enriched sites (all goal-review.ts):
- already-terminal accept + closeGoal (accepted/cancelled) + requestChanges +
  requestGoalReReview: shared `terminalRefusal` helper derives WHO/WHEN from the goal's
  lifecycle trail (non-technical actor label), links the MERGED PR, and self-heals the
  stale card via a `refetch_card` recovery. Extended requestChanges/requestGoalReReview
  selects to load prNumber/prUrl/prState so their refusals can link the PR too.
- deploy-gate failures in assertGoalMergeable (CI-not-passed, unresolved must-fix,
  unreviewed-HEAD): all carry a `deploy_gate_failed` refusal with a "Send back to fleet"
  action + a `<prUrl>/checks` link; the raw CI enum is translated to plain words.
- unverifiable / rate-limited GitHub at accept (single + multi-repo): TRANSLATE — the raw
  error (`String(e)`, status codes) now stays in the server log ONLY, never in the message
  or refusal. Rate-limit → `github_rate_limited` with a self-enabling Retry; resolvesAt
  from the circuit-breaker `openedUntil`, falling back to the ~5-min base cooldown.
- open child pause: `open_pause` refusal deep-linking the specific pause.
- no-credential / no-operator-identity / no-connection / pr-closed / pr-draft /
  merge-conflict: the matching task-1 builder (integrations link, refetch, resolve-conflict).

Tests: a new block asserts each kind's refusal fields — self-heal recovery, PR/checks
links, derived actor/when, and (rate-limit) that the copy contains no `403`. Full engine
suite + typecheck green.

No agent-visible tools/data/workflows change, so no PM Agent config update is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Refusal at every throw site (G#371, task 2 rework)

The prior task-2 commit enriched only the single-repo accept path; the
multi-repo path (mergeGoalRepoTarget / acceptGoalMultiRepo) still threw bare
EngineTransitionError/EngineValidationError with no stable code and no
render-ready refusal, and leaked GitHub's raw detail / `String(e)` (HTTP status)
into user-facing text. This closes those sites to the same bar as single-repo:

- no-credential / no-operator / no-connection (:788/:794/:796): carry the
  no_github_connection code + noCredential/noConnection refusal.
- no-PR-found (:814): no_pr_found code + a not-ready refusal.
- verify catch (:825): TRANSLATE — classify rate-limit vs generic
  (classifyGithubError), attach github_rate_limited/github_unverifiable with a
  resetAt derived from the circuit-breaker (or a ~5-min fallback); raw error in
  the server log ONLY.
- pr closed / draft (:833/:834): pr_closed / pr_draft code + refusal.
- merge catch (:866): plain-language messages (raw body/status logged, never
  shown), permission-denied → mergePermissionDenied code + noCredential refusal,
  conflict → reuse goalMergeConflictReason + mergeConflict code + mergeConflict
  refusal (resolve-conflict route), generic fallback → a Retry refusal (no
  `String(e)` leak).
- aggregate multi-repo throw (:923): propagates the FIRST blocked repo's code +
  refusal so the user still gets ONE concrete next step; summary lists every
  blocked repo (all messages already translated).

Also attaches the no_github_connection code to the single-repo
no-credential/no-operator sites that previously passed `undefined` meta.

Adds 10 multi-repo refusal-field unit tests (each kind's code + refusal.kind +
non-technical copy, incl. that the rate-limit and merge messages carry no '403').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… task #1456)

Deliverable 2 + 3 of "goal action errors must carry a concrete next step": lock in that a
stale Accept on a goal the server has ALREADY moved to a terminal state self-heals the card
instead of stranding the user, and that the kanban Accept honors the single-source-of-truth
`actionable` flag — closing the loop the server refusal contract (#1445) and the cross-surface
refusal renderer (#1446/#1447) opened.

- goals-kanban-view.tsx: the Queue `ready` next-action now gates the Accept on
  `resolveAction.actionable`, not the `kind` label alone. `deriveGoalResolveAction` always
  marks a terminal/underivable goal `actionable:false`, so this can never render a live Accept
  for one; a stale card whose server state already moved to accepted still derives `ready` from
  its stale row, shows Accept once, and the refused click self-heals (acceptGoal →
  goalRefusalToast → applyEngineChange surgical refetch) so the row re-derives to the
  non-clickable terminal state. Behaviour-preserving today, regression-proof going forward.
- goals-kanban-view.test.tsx: two tests for the kanban surface (the detail-page equivalent
  already exists from #1447) — a 409 goal_already_terminal on Accept refetches the row and
  shows NO bare error toast (and no false success), and after the refetch converges the row to
  accepted the Accept re-derives to non-clickable with no lingering error.

No PM Agent config change — UI-only; no new agent-visible tool, entity, or workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Fix CI, move renderer to components/engine (G#371)

Close the last bare-string goal-action failure paths and give the shared refusal
renderer a home outside the goals route group so shared components can use it.

- Move the client refusal renderer + its tests from the goals route dir to
  src/components/engine/goal-action-refusal.tsx (the location the task + CLAUDE.md
  Heal-Before-Add rules name). Its only new consumer, goal-cancel-dialog.tsx, lives
  in components/engine, so a shared renderer no longer imports from deep inside a
  route group. Updated the 3 existing importers (kanban / detail / list) to the
  @/components/engine alias; no behavior change.
- goal-cancel-dialog.tsx: the goal `close`/cancel refusal now renders via
  goalRefusalToast(parseGoalRefusal(res)) — plain-language, recovery-carrying, and
  self-healing (a stale already-terminal cancel refreshes the parent via onCancelled
  instead of stranding the user). The bare toast.error(body?.message) is gone.
- engine-goal-detail-client.tsx handleFixCi: the /fix-ci refusal was the one detail
  goal-action still on toast.error(j?.message ?? ...). Rewired to goalRefusalToast
  with onRefresh (converge a stale card) AND onRetry (re-invoke Fix CI) so the
  github_rate_limited case carries a real Retry, per the retry-recovery contract.
- Tests: new goal-cancel-dialog.test.tsx (mapped refusal → plain-language +
  action, stale self-heal, legacy code-less fallback) and a detail Fix CI refusal
  test (rate-limited → structured toast, no raw "failed: 403" string, carries Retry).

Backward-compat preserved: non-review mutations (priority, task-queue routing,
auto-deliver toggle, delete) keep their plain fallback message — they aren't part of
the GoalActionRefusal contract (rule 3).

No PM Agent config change — UI-only presentation; no agent-visible tool, entity, or
workflow changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Add (G#371)

Enrich the 'Unblock-the-User UX — Heal Before Add' standing rules so the
goal-action refusal surface is named as the reference implementation of the
two binding rules: the server carrier src/lib/engine/goal-action-refusal.ts
and the goal cards (goals kanban + detail) join the already-cited refusal-codes,
http-errors, and client renderer — set alongside the fleet's
machine-blocking-reason.tsx as the same code-keyed, plain-language,
remediation-carrying pattern for a different surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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