Skip to content

workflow: Hand a task to a Workflow swallows the host's refusal — the dialog paints nothing #2237

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 1faec1ce68304f8da2e41546de320739
Branch: dev/1-p449-assign-dialog-refusal
Base: 9863992
At: 2026-11-02T23:59:38Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead
Credit: found by FlyBot Product (P-449 audit on live main).
Kind: bug (a refusal is announced nowhere the user can see it)
Source: FlyBot Product audit P-449; every line below re-read by Lead on live main 986399246ff8c26bd418978785ce1199dbd239ca.

Dedupe (searched before opening)

Open issues matching Hand a task, assign dialog error, wf-assign-fail: none. #2193 (closed) closed the same shape for Rename, Delete and Create only; #2202 closed the duplicate-announcement half. This is the fourth dialog on the same surface — the Assign one — and the backlog entries P-416/P-417 are already landed by #2200/#2213 and #2193.

What is wrong

Hand a task to a Workflow opens AssignModal (plugins/workflow/client.js:1416). When the host refuses the assign, the dialog stays open, the refusal is written into the page-level error state, and the only copy of that sentence is drawn at :2235 — inside the sidebar, behind the modal's own mask (.wf-mask, :2474, position: fixed; inset: 0; rgba(0,0,0,.28); z-index: 80). The dialog itself paints nothing.

  • :2139 confirmAssign — both refusals are setError(value.error ?? ''); return (:2145 for a Bot target, :2157 for a Workflow target): the dialog is not closed and no in-dialog message appears.
  • :1443-1445 — the dialog's only role="alert" sits under tasksStatus === 'failed', i.e. it reports a read failure (tasksError), not a refused assign; AssignModal's props (:1416) do not even carry the page error.
  • :2235 — the page guard is error && !renaming && !deleting && !creating; it has no !assignOpen, so the sentence is drawn exactly when the Assign dialog is the thing covering it.
  • :2228 and :2260 — both entries do setAssignOpen(true); void loadTasks() with no setError(''), so a refusal from the previous open is still on the page when the dialog is re-opened.

So the user gets: dialog still open, nothing changed, and a message they cannot see. It is the same defect #2193/#2202 fixed three times, one dialog further along.

This needs no read-only window to reach: plugins/workflow/src/service.js:1240 answers No such workflow when the id went stale (another window or a host restart), and The host is read-only when another window took the write lock after the dialog opened. Both land in the same path.

Reproduction

A probe that renders the page with a writable roster and a workflow/assign stub returning { ok: false, error: 'That Bot is already in this workflow' }:

PROBE_DIALOGS_OPEN 1
PROBE_IN_DIALOG_ALERTS []            # what the user sees: nothing
PROBE_ALL_ALERTS ["That Bot is already in this workflow"]   # one copy, behind the mask
PROBE_REOPEN_IN_DIALOG_ALERTS []     # closed and re-opened
PROBE_REOPEN_ALL_ALERTS [same sentence, still on the page]

Expected

  1. A refused assign shows exactly one role="alert" inside the open dialog, carrying the host's sentence, and the dialog stays open so the user can retry or cancel.
  2. That sentence appears once in the whole tree — the page-level copy must not also fire, the #2202 contract.
  3. Success unchanged: the dialog closes and the tree carries no alert.
  4. Re-opening the dialog after a refusal starts clean — no sentence left over from the previous attempt.

Where the fix goes

The shape #2193 already used for the other three dialogs fits here unchanged — follow it rather than inventing one:

  • AssignModal (:1416) grows an error prop and draws the sentence as role="alert" inside the dialog body, next to the existing wf-assign-fail slot (:1443) but driven by the refusal, not by tasksStatus === 'failed'. Its prop list today is { workflows, tasks, tasksStatus, tasksError, roster, workflowId, onClose, onConfirm, onRetry } — no error, which is the whole gap;
  • the render site :2285-2288 passes it, exactly as CreateModal already receives error five lines below (:2290);
  • the page guard :2235 gains !assignOpen so the sentence is not drawn twice — the #2202 comment above it (:2231-2234, currently "While Rename, Delete or Create holds this same refusal in its own dialog…") must be extended to name this fourth dialog, not replaced;
  • both entries (:2228 sidebar row, :2260 stage onAssign) clear the stale refusal (setError('')) when the dialog opens;
  • keep the error state (:1944) shared as it is, but scope what the dialog shows: a refusal that belongs to the dialog leaves the page once the dialog closes, and a page action's sentence must not appear inside the dialog.

Red-first (capture on live main before the fix)

  1. after a refusal, the open dialog holds exactly one role="alert" containing the host sentence (today: zero);
  2. the whole tree holds it exactly once (today: one copy, outside the dialog);
  3. reverse: a successful assign closes the dialog and the tree holds no alert;
  4. re-open after a refusal shows no stale sentence.

Shape: copy #2202's tests (plugins/workflow/test/client.test.js:3852-3946), not #2193's — they assert on the whole tree through alertsOf(view) and only then look inside the open dialog, and the comment at :3852-3855 says why: counting inside the dialog is exactly what let the duplicate through. Reuse refusal, mount, settle, findAll, textOf; open the dialog the way #1457's test does (:2336: click the row with className === 'wf-row wf-assign-row', then the button whose text is Assign).

Scope

plugins/workflow/client.js (AssignModal, confirmAssign, the page guard and the two entries), plugins/workflow/test/client.test.js, docs/subsystems/workflow.md and one changelog.d/ entry.

  • Tests: the baseline is whatever node --test plugins/workflow/test/client.test.js prints on the branch base (142/142 as reported on 98639924); assertions are added, never removed, and the read-failure case at :2390-2423 plus the read-only cases at :3827-3846 must stay green.
  • Docs: docs/subsystems/workflow.md:17 currently says "A confirmation dialog the host refuses — Rename, Delete, or Create — keeps the dialog open and prints the host's own sentence inside the dialog as the only role="alert" for that refusal … Opening any of the three clears the previous refusal". That sentence becomes four dialogs, naming Hand a task to a Workflow; do not weaken the #2193/#2202 wording, extend it.
  • Changelog: one new file named after this issue, in the existing shape (changelog.d/2202-single-refusal-alert.fixed.md), e.g. changelog.d/<issue>-assign-dialog-refusal.fixed.md.

The dialog's existing wf-assign-fail slot (:1443) stays for the read failure (tasksStatus === 'failed'); the refused assign must not light both.

Not in scope: the live-row accessible name (P-445), the read-only ingest switch (P-446) and the target row's Project badge (P-450) — all in this same file, so this PR ships alone and the others rebase on it.

Tier: T2 (plugins/workflow client surface).

Activity

  1. HugoXK commented on Nov 3, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: 1faec1ce68304f8da2e41546de320739
    Reason: merged as 7d1f1bb (PR #2240, tier T2, Birfy verdict PASS at ee264e2)
    At: 2026-11-03T00:46:10Z

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