feat(web): add a Failed tab to the Needs-you Inbox (#3683)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-16 06:14:56 +08:00
committed by GitHub
parent 02120b907c
commit 37cea5a8c2
78 changed files with 4895 additions and 55 deletions
+29 -10
View File
@@ -3,10 +3,12 @@ created: 2026-09-12
status: complete
requirements:
- REQ-UI-NEEDS-YOU-INBOX-001
- REQ-UI-INBOX-FAILED-001
system_design:
- ../../specs/ui/system-design/needs-you-inbox-01.md
- ../../specs/ui/system-design/needs-you-inbox-02.md
- ../../specs/ui/system-design/needs-you-inbox-03.md
- ../../specs/ui/system-design/inbox-failed-bucket-01.md
legacy_specs: []
---
@@ -30,6 +32,12 @@ document exists to satisfy the repository's PR documentation coverage gate
(`ci: enforce pull request documentation coverage`), which was not wired into
that workflow when this feature's spec canon was authored.
Task 03 adds the Inbox's second bucket, the failed-task bucket, per
[requirements](../../specs/ui/requirements/inbox-failed.md) and
[system design](../../specs/ui/system-design/inbox-failed-bucket-01.md). It
was explicitly out of scope for Task 01 and is delivered as a separate work
order against the same Inbox surface.
## Scope
### In scope
@@ -41,34 +49,45 @@ that workflow when this feature's spec canon was authored.
- In-place answer via the shipped `ClarificationPanelSection`.
- Per-user dismiss/snooze/restore sidecar that never mutates the underlying
clarification record.
- The Inbox's second, Failed tab: a bounded list of terminal-`FAILED` tasks,
deliberately excluded from the existing Needs-you badge count
(Task 03, `REQ-UI-INBOX-FAILED-001`).
- i18n in all five shipped locales.
### Out of scope
Re-ask, the history bucket, the failed-task bucket, cross-workspace rows, and
any change to the current-turn predicate's meaning. See the requirements
document's own "Out of scope" section for the full list.
Re-ask, the history bucket, cross-workspace rows, and any change to the
current-turn predicate's meaning. See the requirements documents' own
"Out of scope" sections for the full list.
## Work orders
- [x] [Task 01: Needs-you Inbox delivery](task-01-needs-you-inbox-delivery.md)
- [x] [Task 02: Inbox feature-toggle terminology](task-02-inbox-label-alignment.md)
- [x] [Task 03: Inbox Failed tab delivery](task-03-inbox-failed-tab.md)
- [x] [Task 03: Fix background-refresh loading state](task-03-fix-background-refresh-loading-state.md)
Task 02 depends on Task 01 and records the follow-up display-name alignment.
Task 03 depends on Task 01 and fixes a `resolveViewMode` / WS-coalescing
regression against AC .19-.21; see its own file for scope and verification.
Task 03 depends on Task 01's shipped Inbox surface (tab strip host, route,
sidebar entry) but touches no file Task 01 did not already establish as
extensible; it is otherwise independent of Task 02's label change.
The background-refresh work also depends on Task 01 and fixes a
`resolveViewMode` / WS-coalescing regression against AC .19-.21; see its own
file for scope and verification.
## Verification results
See the work order for the full verification record. Summary: backend and
See each work order for its full verification record. Summary: backend and
frontend build/lint/typecheck clean; targeted and full package test suites
pass (pre-existing, unrelated failures proven against merge-base in a scratch
worktree); desktop and mobile E2E specs for the answer-in-place flow pass;
`pnpm run i18n:check` and `pnpm run i18n:ratchet` clean; `python3
scripts/lint-spec-files.py --all` passes.
worktree); desktop and mobile E2E specs for the answer-in-place and
failed-tab flows pass; `pnpm run i18n:check` and `pnpm run i18n:ratchet`
clean; `python3 scripts/lint-spec-files.py --all` passes.
PR: https://github.com/kdlbs/kandev/pull/3641
PRs: https://github.com/kdlbs/kandev/pull/3641 (Task 01),
https://github.com/kdlbs/kandev/pull/3673 (Task 02),
https://github.com/kdlbs/kandev/pull/3683 (Task 03)
## Task 02 results
@@ -0,0 +1,121 @@
---
id: "03-inbox-failed-tab"
title: "Inbox Failed tab delivery"
status: done
wave: 3
depends_on: ["01-needs-you-inbox-delivery"]
plan: "plan.md"
requirements:
- REQ-UI-INBOX-FAILED-001
acceptance_criteria:
- AC-UI-INBOX-FAILED-001.1
- AC-UI-INBOX-FAILED-001.2
- AC-UI-INBOX-FAILED-001.3
- AC-UI-INBOX-FAILED-001.4
- AC-UI-INBOX-FAILED-001.5
- AC-UI-INBOX-FAILED-001.6
- AC-UI-INBOX-FAILED-001.7
- AC-UI-INBOX-FAILED-001.8
- AC-UI-INBOX-FAILED-001.9
- AC-UI-INBOX-FAILED-001.10
- AC-UI-INBOX-FAILED-001.11
- AC-UI-INBOX-FAILED-001.12
- AC-UI-INBOX-FAILED-001.13
- AC-UI-INBOX-FAILED-001.14
- AC-UI-INBOX-FAILED-001.15
- AC-UI-INBOX-FAILED-001.16
- AC-UI-INBOX-FAILED-001.17
- AC-UI-INBOX-FAILED-001.18
- AC-UI-INBOX-FAILED-001.19
- AC-UI-INBOX-FAILED-001.20
- AC-UI-INBOX-FAILED-001.21
- AC-UI-INBOX-FAILED-001.22
- AC-UI-INBOX-FAILED-001.23
- AC-UI-INBOX-FAILED-001.24
- AC-UI-INBOX-FAILED-001.25
- AC-UI-INBOX-FAILED-001.26
- AC-UI-INBOX-FAILED-001.27
- AC-UI-INBOX-FAILED-001.28
- AC-UI-INBOX-FAILED-001.29
- AC-UI-INBOX-FAILED-001.30
system_design:
- ../../specs/ui/system-design/inbox-failed-bucket-01.md
---
# Task 03: Inbox Failed Tab Delivery
## Summary
Add the Inbox's second bucket: a Failed tab that lists tasks in the terminal
`FAILED` state, deliberately off the existing Needs-you badge. The Needs-you
tab's rows, ordering, count, answer affordances, dismiss/snooze, and error
state are untouched; the only permitted delta to that tab is one added empty
state copy clause (`AC-UI-INBOX-FAILED-001.23` / `.4`).
## In scope
- Backend: `internal/failedinbox` (bounded list read, reason resolution),
`internal/task/repository/sqlite/failed_inbox_query.go`
(SQLite/Postgres-compatible query), and flag-gated route registration
(`features.needsYouInbox`, reusing the existing Inbox flag rather than
adding a new one).
- Frontend: `failedInbox` store slice and selectors, `inbox-tab-strip`,
`failed-inbox-tab-panel`/`-row`/`-empty-state`/`-error-state`,
`useFailedInboxController`, i18n in all five shipped locales.
- Desktop and mobile E2E coverage for listing a failed task without inflating
the sidebar's Needs-you count (AC .29).
- A workspace-revision fix (`office-routes.tsx`, `settings-routes.tsx`) for a
Zustand hydration race the tab-strip's workspace-switch handling surfaced:
capture `activeId` before `hydrate()`, keep `hydrate()`'s own `activeId`
unchanged, then call `setActiveWorkspace()` explicitly when it differs, so
`activeIdRevision` stays accurate.
## Out of scope
Re-ask, the history bucket, cross-workspace rows, and any change to the
current-turn predicate's meaning or the Needs-you tab's own row/ordering/count
behavior. See the requirements document's own "Out of scope" section for the
full list.
## Acceptance
All 30 numbered acceptance criteria under `REQ-UI-INBOX-FAILED-001`, plus the 6
lettered sub-criteria the requirements document defines under them (.9a, .10a,
.18a, .19a, .20a, .30a — each refines its parent numbered AC rather than
standing as a separate tracked entry); see
[requirements](../../specs/ui/requirements/inbox-failed.md) for the
full text. Notable ones with dedicated regression coverage:
- AC .14/.29: the sidebar's Needs-you count never moves when a failed task
exists, checked before the Failed tab is ever selected -
`apps/web/e2e/tests/chat/inbox-failed-tab.spec.ts`.
- AC .16: a re-visited workspace's stale Failed badge clears even when the
workspace switch happened while the Inbox was closed throughout -
`inbox-failed-tab.spec.ts` ("clears a re-visited workspace's stale badge...
Inbox closed").
- AC .4/.23: the Needs-you empty state gains exactly one added copy clause and
no other behavior change - `needs-you-inbox-empty-state.test.tsx`.
- AC .18: the open-task control stays reachable at every viewport width (no
actions-menu fallback) - `failed-inbox-row.tsx` / `failed-inbox-row.test.tsx`.
## Verification
- `go build ./...`, `go vet ./...` clean (apps/backend).
- `go test ./internal/failedinbox/... ./internal/task/repository/sqlite/...`
- all packages `ok`.
- `make -C apps/backend lint` - 0 issues.
- `pnpm run typecheck`, `pnpm exec eslint` (touched files) - clean.
- `pnpm exec vitest run` (targeted `failed-inbox`, Office/Settings
workspace-revision suites) - all passing.
- `pnpm run i18n:check` / `pnpm run i18n:ratchet` - five locales complete, no
em-dash violations, guard allowlist intact.
- `pnpm e2e:run` - desktop `inbox-failed-tab.spec.ts` and mobile
`mobile-inbox-failed-tab.spec.ts` both pass with a full rebuild.
- `python3 scripts/lint-spec-files.py --all` - all specification files
passed.
- Full local-suite pre-existing failures (macOS `/var`-symlink worktree
issues, no local Docker daemon, wall-clock-timing tests under host load)
proven pre-existing against `git merge-base HEAD origin/main` in a scratch
worktree; unrelated to this branch's diff.
PR: https://github.com/kdlbs/kandev/pull/3683
+1 -1
View File
@@ -508,7 +508,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. |
| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. |
| `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. |
| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Inbox for clarification questions across tasks in the active workspace. |
| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Inbox for clarification questions and failed tasks in the active workspace. The Failed tab shows a bounded list with its own count. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `features.agentSurvival` | `KANDEV_FEATURES_AGENT_SURVIVAL` | off | Experimental. Lets the `agentctl` control server and its running agents outlive a backend restart or upgrade, for the `local_pc` and worktree executors only. The restarted backend takes the surviving server over and reconnects to the sessions still running on it. **Unavailable on Windows**, where surviving the backend means giving up the Job Object that guarantees agent processes are cleaned up. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. |
+322
View File
@@ -0,0 +1,322 @@
---
status: draft
system: ui
created: 2026-09-14
owners:
- nova28
---
# Inbox Failed Bucket Requirements
## Overview
The Inbox shipped with one bucket. Its badge counts questions blocking an agent
and nothing else, which is correct and is not changed here. What it leaves out is
the other way an agent stops being useful: it fails, and nothing says so. Measured
2026-09-14, archived excluded: 15 tasks sit in the terminal `FAILED` state, and none
appears on any list an operator opens.
This capability adds the Inbox's second bucket: failed tasks, present and
deliberately off the badge. A failed task is triage, not a decision the operator
owes an agent, so it must be reachable without ever inflating the number that
means "someone is blocked on you". UI owns the bucket, the tab, the row, and the
exclusion from the count. Task failure itself, its state machine, and its retry
behaviour stay owned by the tasks and workflow systems, consumed here unchanged.
This file states the contract. The
[system design](../system-design/inbox-failed-bucket-01.md) argues it, and carries
the input inventory, the prior art and its receipts, and the reasoning behind the
Out of scope entries that turn on a judgement rather than a scope boundary.
## Terminology
- **Failed task:** A task whose own state is the terminal `FAILED` state and which
is not archived. Defined at the task, not the session: AC-UI-INBOX-FAILED-001.6.
- **Failure instant:** The completion instant of the session
AC-UI-INBOX-FAILED-001.30 selects. Not the task's last-updated time.
- **Actionable count:** As the Inbox already defines it, the number of rows the
operator can act on now. Failed tasks are never part of it.
## Requirements
### REQ-UI-INBOX-FAILED-001: Inbox failed bucket
**Intent:** Make every failed task in the active workspace reachable from the
Inbox in one place, ordered newest first, without adding a single unit to the
count that means an agent is blocked on the operator.
**User story:** As an operator, I want the Inbox to show me what failed, so that
a dead agent run is discoverable without opening the board, and so that the
number on the sidebar still only ever means someone is waiting on my answer.
#### Acceptance criteria
**Tab strip and destination**
- **AC-UI-INBOX-FAILED-001.1:** The Inbox shall render a tab strip with exactly two
tabs, in the order Needs you then Failed, and Needs you shall be selected when no
tab is otherwise specified.
- **AC-UI-INBOX-FAILED-001.2:** The selected tab shall be addressable through the
`tab` query parameter on the existing Inbox route, whose only recognised values are
`needs-you` and `failed`. Opening `?tab=failed` shall render the Failed tab
directly, without the operator selecting it and without first rendering Needs you.
An absent, empty, repeated or unrecognised value shall render Needs you rather than
an error. Selecting a tab shall REPLACE the current history entry, not push one.
- **AC-UI-INBOX-FAILED-001.3:** Where the Inbox feature flag is disabled, no tab
strip and no failed bucket shall render, by the same gating that already governs
the Inbox destination.
- **AC-UI-INBOX-FAILED-001.4:** The Needs you tab's row set, ordering, count,
answer affordances, dismiss and snooze behaviour, and error state shall be
unchanged by this capability. Its empty state shall be unchanged except for the
single copy clause AC-UI-INBOX-FAILED-001.23 adds: same render conditions, row
set, structure and workspace-naming rule, that clause the only observable
difference. A test shall assert the enumerated behaviour is identical before and
after, asserting behaviour rather than empty-state copy.
**Row set**
- **AC-UI-INBOX-FAILED-001.5:** The Failed tab shall list exactly those tasks
that are failed tasks and whose workspace is the active workspace, and no
others, subject to the bound in AC-UI-INBOX-FAILED-001.12. Switching the
active workspace shall replace the list with that workspace's failed tasks.
- **AC-UI-INBOX-FAILED-001.6:** A row shall be derived from the task's own terminal
failed state, never from a session state alone. Given a fixture of tasks whose
primary session is failed but whose task state is not terminal failed, the Failed
tab shall list zero rows for them.
- **AC-UI-INBOX-FAILED-001.7:** An archived task shall never be listed, whatever
its state.
- **AC-UI-INBOX-FAILED-001.8:** When a listed task stops being a failed task for any
reason, including retry, re-state or archive, the Failed tab shall remove its row
without the operator reloading or re-navigating. The row set shall converge by
re-reading the same path, so no exit can strand a row by emitting no event. While
the Inbox is open, a workspace is active and the browser tab is visible, that
convergence shall complete within 60 seconds of the change when that window's
re-read succeeds, and a tab returning to visible shall re-read at once rather than
waiting out that interval. A failing read falls to AC-UI-INBOX-FAILED-001.22.
Neither bound depends on which tab is selected.
- **AC-UI-INBOX-FAILED-001.9:** A failed task shall be listed regardless of origin.
Given a fixture in which most failed tasks are automation runs, the Failed tab
shall list all of them rather than suppressing them.
- **AC-UI-INBOX-FAILED-001.9a:** `manual` shall be the only origin treated as a
person; every other value shall be treated as not a person. A `manual` row shall
render no origin marker. Each of the five non-person origins shall render its own
distinct marker, not one shared word: `agent_created`, `routine`, `onboarding`,
`automation_run`, `automation_task`. A non-empty origin this capability does not
enumerate shall render a stated generic non-person marker, never nothing, never
the raw value, never a failed row. An absent or empty origin shall render no
marker, taking the `manual` path.
**Ordering and boundaries**
- **AC-UI-INBOX-FAILED-001.10:** Rows shall be ordered by three keys in this
order: first, whether the failure instant is unresolvable, unresolvable before
resolved; second, failure instant descending; third, `tasks.id` ascending,
compared as a byte-ordered string as in AC-UI-INBOX-FAILED-001.30a, never by a
collation the storage engine chooses. The order shall be total: the same set of
failed tasks shall always produce the same row order, so the unresolvable group is
itself ordered by `tasks.id`.
- **AC-UI-INBOX-FAILED-001.10a:** The placement of unresolvable failure instants
shall be an explicit sort key and shall never be left to the storage engine's
default placement of absent values, on which this product's two engines disagree.
The row order shall be identical under every engine this product supports, and a
test shall assert it under each rather than one.
- **AC-UI-INBOX-FAILED-001.11:** The failure instant shall be the completion instant
recorded on the session AC-UI-INBOX-FAILED-001.30 selects, never the task's
last-updated time. Given a fixture whose task was written after it failed, the row
shall report the instant it failed rather than when it was last written. Where that
session records no completion instant, the failure instant is unresolvable and
AC-UI-INBOX-FAILED-001.30 governs; no other field shall be substituted.
- **AC-UI-INBOX-FAILED-001.12:** A single read shall return at most a bounded
page, matching the bound the Needs you bucket already uses: an absent limit
shall default to 50, a limit above 200 shall be clamped to 200 rather than
rejected, and a limit of 200 or below shall be honoured exactly. When more
failed tasks exist than the page returns, the Failed tab shall indicate the list
is truncated rather than presenting a partial list as complete. The response
shall carry that truncation as its own explicit field, never inferred from the
presence of a cursor, a next-page link, or any other optional field.
- **AC-UI-INBOX-FAILED-001.13:** A read shall reject a missing or empty workspace
identifier and a non-numeric or non-positive limit, each as a client error that
lists no rows and renders the error state, never silently substituting a default.
A read matching nothing shall return an empty list rather than an absent one. The read shall accept and return no cursor: it serves
exactly one bounded page per AC-UI-INBOX-FAILED-001.12, whose truncation
indicator is all it says about rows beyond the bound.
**Count**
- **AC-UI-INBOX-FAILED-001.14:** The sidebar count shall be unchanged by this
capability. No failed task shall contribute to it through any producer, including
the boot-hydrated producer that renders it before the Inbox is opened.
- **AC-UI-INBOX-FAILED-001.15:** Given a fixture with one or more failed tasks and
no answerable bundles in the active workspace, the sidebar entry shall render no
count at all. A zero shall not be rendered in place of the absent count, and the
presence of failed tasks shall not make it appear.
- **AC-UI-INBOX-FAILED-001.16:** The Failed tab shall carry its own count, on the
tab and not in the page header, rendered as a secondary-variant badge inside the
tab control. That count shall be the number of rows carried by the most recent
response successfully applied for the active workspace, never a separately computed
total, a count from the start of the result set, or any number that response did
not carry alongside its rows. Whenever the Failed tab is selected that response is
also the one supplying its rows, so no rendered state exists in which the count and
the visible list disagree. It shall remain rendered while the other tab is selected,
since the tab strip is visible from both. No count shall be rendered before
a response has been applied for the active workspace, nor where it is zero; an
unread bucket shall not be presented as one known to hold zero. Changing the active
workspace shall clear it until a response for the new one is applied.
- **AC-UI-INBOX-FAILED-001.17:** When the failed page is truncated, the tab's count
shall present the bounded value as a capped indicator rather than an exact total,
by the same presentation the Needs you count already uses. Given a bound of 200
and more than 200 failed tasks, the tab shall render the row count marked as
capped, never a bare 200 and never a total the response did not carry.
**Row content**
- **AC-UI-INBOX-FAILED-001.18:** A row shall present the task's title, the reason it
failed, how long ago it failed, and a control that opens the task, reachable at
every viewport width including where a fixed-width button would be withheld.
- **AC-UI-INBOX-FAILED-001.18a:** A row's failure marker shall be the shipped
TASK-state failed pairing from the shared state-icon module, not that module's
session-state one, which is a different pairing: this bucket is defined by task
state per AC-UI-INBOX-FAILED-001.6. This capability shall add no new colour; the
marker shall use the module's existing error style. A test shall assert the row
renders that module's task-state pairing rather than a local copy of it.
- **AC-UI-INBOX-FAILED-001.19:** The failure reason shall be the error the task's
session recorded, truncated by the server so one long message cannot inflate a
full page; a message longer than the bound shall be truncated at the boundary
rather than omitted or rejected. Where no reason can be resolved, or the value the
server returns is empty or only whitespace, the row shall render a stated fallback
rather than a blank line, and shall still render the rest of the row. That
emptiness test applies to the value the server returns, after
AC-UI-INBOX-FAILED-001.19a's truncation, so a reason whose first 512 code points
are all whitespace takes the fallback.
- **AC-UI-INBOX-FAILED-001.19a:** The server shall produce the returned reason in
this order, and the order is part of the contract because the steps are not
commutative. FIRST, the stored value shall be made valid UTF-8 by replacing each
byte that is not part of a well-formed sequence with the Unicode replacement
character, so what is measured and returned is valid UTF-8 for every input,
including one that is not. SECOND, the result shall be truncated to at most 512
Unicode code points, not bytes, never splitting a code point. A reason already
valid UTF-8 and 512 code points or fewer shall be returned unchanged; that covers
this case only and is no licence to return invalid UTF-8 that happens to be short.
Given a 600-code-point reason in a script encoding to more than one byte per
character, the response shall carry exactly the first 512 and no partial trailing
character. The response shall carry no truncated flag for the reason and the row
shall render no marker.
- **AC-UI-INBOX-FAILED-001.30:** The failure instant and the failure reason shall
be resolved from exactly one of the task's sessions, by a rule total and
deterministic for any task: the session marked primary; where more than one is,
the one that started most recently; where none is, the session that started most
recently; and where the task has no session, neither field is resolvable. A failed task shall be listed in every one of those cases, including
the last: an unresolvable failure instant shall place the row first per
AC-UI-INBOX-FAILED-001.10a, with the row stating the failure time is unknown
rather than a substituted time, and an unresolvable reason shall render
AC-UI-INBOX-FAILED-001.19's fallback. No resolution failure
shall remove a failed task that AC-UI-INBOX-FAILED-001.12's bound would otherwise
have included; the bound is the only thing that may elide a row.
- **AC-UI-INBOX-FAILED-001.30a:** Where two or more candidate sessions tie at the
most recent start instant, the session with the lowest session identifier,
compared as an ascending byte-ordered string, shall be chosen, so repeating a read
against unchanged data always selects the same session as
AC-UI-INBOX-FAILED-001.25 requires. The start instant is non-nullable, so this
covers an equal value, not an absent one.
- **AC-UI-INBOX-FAILED-001.20:** Every row on the Failed tab shall present the
shared thread status vocabulary's failed status, the entry the rest of the
product already uses, which carries no attention flag. It shall be selected by
the task's own terminal failed state, which every listed row has by
AC-UI-INBOX-FAILED-001.6, and shall not be resolved per row from any session's
state. Because that vocabulary's resolvers take a session and this row model
carries none, the failed entry shall be reached directly, never by synthesising a
stand-in session to pass to a resolver. Exporting that existing entry from the
module that already defines it is permitted and is not a new entry. This
capability shall define no second status vocabulary, add no entry to the existing
one, and duplicate no entry's value locally.
- **AC-UI-INBOX-FAILED-001.20a:** No row on the Failed tab shall render a status
carrying an attention flag, under any resolution outcome of
AC-UI-INBOX-FAILED-001.30, including a task with no session at all. Given a
fixture whose resolved session carries a pending permission request or a pending
clarification, the row shall still present the failed status.
**Empty and error**
- **AC-UI-INBOX-FAILED-001.21:** When the Failed tab lists no rows for the active
workspace, it shall render an empty state that names the active workspace, does
not congratulate the operator, and does not claim they are caught up. Where the
workspace's name cannot be resolved from client state, the copy shall name no
workspace rather than the wrong one.
- **AC-UI-INBOX-FAILED-001.22:** If a failed-bucket read fails, then the Failed tab
shall render an error state distinct from its empty state, shall not state that
nothing failed, and shall render no count. A failure of one tab's read shall not
empty, error, or otherwise disturb the other tab, except for the single clause that
AC-UI-INBOX-FAILED-001.23 governs.
- **AC-UI-INBOX-FAILED-001.23:** The Needs you tab's empty state shall name
failed tasks among the things it is not counting, and shall say where they are.
Where the active workspace holds at least one failed task, it shall say so
rather than implying the workspace is quiet; where it holds none, it shall not
imply that any exist; and where the failed count is not known, because no failed
read has been applied for the active workspace or the last one failed, it shall
omit the clause entirely rather than assert either. This shall hold on the first
render of the Needs you empty state, without the operator selecting the Failed tab
first.
**Security**
- **AC-UI-INBOX-FAILED-001.24:** If a failed-bucket read names a workspace
outside the caller's visible workspace set, then the server shall reject it with
the same non-disclosing not-found outcome the Inbox already uses rather than an
empty success, and the Failed tab shall render its error state rather than its
empty state. Scoping shall be enforced server-side from the request identity; a
client-supplied workspace identifier shall not by itself grant access. Where
authentication is disabled, the single-user identity reaches every workspace.
**Idempotency, concurrency, and staleness**
- **AC-UI-INBOX-FAILED-001.25:** The failed-bucket read shall be free of side
effects and idempotent: repeating it with the same parameters against unchanged
data shall return the same rows in the same order and write nothing. Nothing in
this capability shall write task state.
- **AC-UI-INBOX-FAILED-001.26:** When two failed-bucket reads are in flight, only
the response matching the currently active workspace and the most recently
issued read shall be applied, so a slower earlier response can neither replace
the active list nor resurrect a row that a newer response dropped. That guard
shall NOT key on the selected tab: the failed read runs regardless of which tab
is selected per AC-UI-INBOX-FAILED-001.23, so a response that arrives while Needs
you is selected shall still be applied.
- **AC-UI-INBOX-FAILED-001.27:** While a task changes state concurrently with a
read, the read shall return either the row or no row and shall not fail. Two
operators reading one workspace at the same instant shall each receive a
self-consistent page.
**Copy and coverage**
- **AC-UI-INBOX-FAILED-001.28:** All copy added by this capability shall be
localized in the five shipped locales, shall contain no Unicode em dash, and the
repository i18n ratchet shall pass.
- **AC-UI-INBOX-FAILED-001.29:** An end-to-end specification shall cover observing
the Failed tab's count before that tab is ever selected, then selecting it,
observing a failed task listed with its reason and relative failure time, opening
the task from the row, and observing that the sidebar count did not change when
the failed task appeared.
## Out of scope
Each entry is a stated exclusion, not an omission.
- **Pagination past the first bounded page.** The read serves one page of at most
200 rows and says, through AC-UI-INBOX-FAILED-001.12's truncation indicator,
that more exist; it takes and returns no cursor.
- **Dismiss, snooze, and restore for failed rows.** A failed task already has three
real exits, all removing the row under AC-UI-INBOX-FAILED-001.8: retry, move,
archive.
- **Retrying or restarting a task from the Inbox.** The row's only control opens the
task.
- **The history bucket ("Asked, unanswered" in the deck).** A separate sibling
capability. The tab strip has room; the third tab is not built here.
- **Severity, ranking, or grouping of failures.** Every failed task is one row. No
dot, no chip, no rollup of repeated identical failures.
- **Notification on failure.** Enabling this emits no notification for a task that
already failed, and adds none for a task that fails later.
- **Retention, archival, or pruning of failed rows.** Archived tasks are excluded by
AC-UI-INBOX-FAILED-001.7.
- **Cross-workspace rows.** Workspace-scoped, by the same product decision and
permission barrier that scopes the Needs you bucket.
- **Any change to what makes a task failed**, to its state machine, or to the
workflow engine's behaviour on agent error. Consumed unchanged.
@@ -0,0 +1,526 @@
---
status: draft
system: ui
requirements:
- REQ-UI-INBOX-FAILED-001
---
# Inbox Failed Bucket System Design
The Inbox itself is designed in
[`needs-you-inbox-01.md`](needs-you-inbox-01.md),
[`needs-you-inbox-02.md`](needs-you-inbox-02.md) and
[`needs-you-inbox-03.md`](needs-you-inbox-03.md). This design adds one bucket to it,
and changes exactly one thing those three own: the Needs you empty state gains a
single copy clause, specified by `AC-UI-INBOX-FAILED-001.23` and carved out as the
sole permitted delta by `AC-UI-INBOX-FAILED-001.4`. Their behaviour, row set,
ordering, count, answer affordances, dismiss and snooze, and error state are
untouched.
## Purpose and boundaries
UI owns the bucket, the tab, the row, and the exclusion from the count. It does
not own what makes a task failed, when a failed session is retried, or how a
task is archived. Those are the tasks and workflow contracts, consumed here
through a read-only projection.
Two boundaries are worth naming because getting either wrong turns this into a
different change:
- **The badge producer is not touched.** The Inbox badge is produced by the
clarification-inbox read and its boot-hydration twin. This capability adds a second
read and a second count, rendered on a tab. No failed-task code path may write the
badge's per-workspace state key.
- **The failed read is a separate endpoint from the bundle read.** They answer
different questions over different tables, and merging them would make one
bucket's failure the other bucket's failure, which
AC-UI-INBOX-FAILED-001.22 forbids.
## Prior art
**Our own prior reasoning (wiki).** Receipt: the vault resolved through
`~/.obsidian-wiki/config` to `OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`,
collection `wiki`. It could not then be read: `qmd` is not on `PATH`, its MCP server
is not exposed, `obsidian-wiki` is not installed, and the directory is unreadable
from this sandbox (`Operation not permitted`), so no grep fallback was possible. This leg returned nothing because the vault was
unreachable, not because it held nothing.
**What other products shipped (saas-kb).** Receipt: `search_saas_docs` with
`category: "ai_sdlc"`, queries "inbox badge count actionable items failed runs
triage surface" and "sidebar badge unread count excluded from count failed runs do
not require action", then `get_saas_doc` on the top hit. The first returned
substantive Paperclip and Factory.ai hits; the second collapsed onto unrelated
vendors and was discarded.
Paperclip's Inbox is the closest shipped analogue (tabs: Mine, Recent, Unread,
Blocked, All). Three decisions taken from it: tab switching navigates, since its
`/inbox/<tab>` "doesn't just hide content", so a tab is bookmarkable; failed runs
are a category rather than a queue, though we place them in a dedicated tab rather
than a filter inside a firehose, because two buckets are not six categories and a
filter must be discovered; and per-item hiding is scoped to one tab, which we reach
for the different reason in [Out-of-scope reasoning](#out-of-scope-reasoning).
Where we differ deliberately: Paperclip's Blocked tab carries a severity dot and a
blocked-reason chip, and its Mine tab is "the queue that matters most". We rank no
failures by severity and do not treat this bucket as a queue.
## Requirement mapping
`REQ-UI-INBOX-FAILED-001` maps to the sections below, per acceptance criterion.
| Acceptance criteria | Design section |
| --- | --- |
| .1-.4 | [Components and responsibilities](#components-and-responsibilities) |
| .5-.9 | [Input inventory](#input-inventory), [The failed-task predicate](#the-failed-task-predicate) |
| .9a | [Data and contracts](#data-and-contracts) |
| .10, .10a, .11-.13 | [Ordering, paging, and the failure instant](#ordering-paging-and-the-failure-instant) |
| .14-.17 | [Count separation](#count-separation), [Ordering, paging, and the failure instant](#ordering-paging-and-the-failure-instant) |
| .18, .18a, .19, .19a | [Data and contracts](#data-and-contracts), [Copy and coverage](#copy-and-coverage) |
| .20, .20a, .30, .30a | [Session resolution](#session-resolution) |
| .21-.23, .27 | [Failure and recovery](#failure-and-recovery), [Count separation](#count-separation), [Control flow](#control-flow) |
| .24 | [Security](#security) |
| .8, .25-.26 | [Control flow](#control-flow) |
| .28-.29 | [Copy and coverage](#copy-and-coverage) |
## Input inventory
Sampled from the live instance on 2026-09-14 against
`/Users/henry/.kandev/data/kandev.db` opened read-only. Every number below is a
measurement, not an estimate.
| Candidate predicate, archived excluded | Rows |
| --- | --- |
| `tasks.state = 'FAILED'` | 15 |
| task has a primary session in state `FAILED` | 18 |
| task has any session in state `FAILED` | 22 |
The three-row gap decomposes as: 15 tasks where both hold, plus one task in
`CREATED`, one in `REVIEW` and one in `SCHEDULING`, each with a failed primary
session. No task is in `FAILED` state without a failed primary session, so the first
set is a strict subset of the second and task state is reliably written.
Other measured facts the contract depends on:
- `task_sessions.error_message` on a failed session: never empty across all 111
failed sessions, mean 199 bytes, maximum 4096.
- `task_sessions.completed_at` on a failed session: never null across those 111.
- `tasks.updated_at` drifts from the session completion instant: zero on 14 of the
15 failed tasks, and 538,095 seconds (6.2 days) on one.
- Origin of the 15 failed tasks: 13 `automation_run`, 2 `manual`. `is_ephemeral`
is 0 on all 18 primary-failed tasks, so it does not discriminate and `origin`
does.
- `tasks.labels` is `[]` on every failed task, so the deck's row subtitle cannot be
sourced from labels.
- Workspace spread of the 15: 14 in the Kandev workspace, 1 elsewhere, so the
default page bound is never reached today.
- 42 archived tasks carry a failed primary session, which is why
AC-UI-INBOX-FAILED-001.7's exclusion is load-bearing rather than theoretical.
## The failed-task predicate
**Decision: a row exists when `tasks.state` is the terminal failed state and
`tasks.archived_at` is null.** Rejected alternatives:
- **Primary session is `FAILED`.** What the deck measured, and wrong as a predicate.
It admits the `SCHEDULING` task, which the system is actively re-placing and which
will run again without anyone triaging it, and the `REVIEW` task, which has already
moved on. The deck's 17 was a volume-and-recency argument, not a definition, and the
deck does not own the data contract.
- **Any session is `FAILED`.** Admits every task that ever failed once and then
succeeded: 22 rows today, at least 7 of them recoveries.
The chosen predicate is the one the rest of the product already uses:
`IsTerminalTaskState` in `apps/backend/internal/task/models/models.go` treats
completed, failed and cancelled alike. Deriving the bucket from a fact the task model
already asserts means a retry removes the row by changing task state, which is the
convergence AC-UI-INBOX-FAILED-001.8 requires, rather than by a session count the
Inbox would re-derive.
The cost, stated plainly: a task that dies without its state being written to failed
will not appear. Today that set is empty. If it stops being empty, the fix is in the
task state machine, not a second predicate here.
## Components and responsibilities
| Component | Responsibility |
| --- | --- |
| Failed-bucket read endpoint | Workspace-scoped, bounded, read-only projection of failed tasks. New. |
| Failed-bucket store query | One query joining the task to the session [Session resolution](#session-resolution) selects, for the failure instant and reason. Usually the primary session but not always, so the join follows the selection rule, not `is_primary`. New. |
| Inbox page tab strip | Two tabs, `variant="line"` from the shared `Tabs` component; each count a secondary-variant `Badge` inside its tab control. New. |
| Failed row | Title, reason, relative failure time, open-task control, and the shared module's TASK-state failed marker. New. |
| Failed-bucket state slice | Per-workspace rows, count, truncation flag, read status, generation guard. New, modelled on the existing Needs-you slice. |
| Needs-you bucket, its slice, its controller, its badge | Unchanged. |
| Shared thread status vocabulary | Its `failed` entry is reused for row status, reached directly rather than through `resolveThreadSessionStatus`: see [Session resolution](#session-resolution), the resolvers take a session and this row has none. |
The deck settles where the count goes: inside the tab, not the page header, because
the header is where a title would go and this page has none.
## Data and contracts
The failed row carries, per task: `task_id`, `title`, `workspace_id`, `origin`,
`failure_instant`, and `reason`. `failure_instant` is OMITTED when unresolvable,
never null and never a zero instant, which is how the client tells it from a real
one; the other five keys are always present, empty string rather than absent. It carries no session identifier and
no session state in the row model, because the row addresses a task, the
open-task control needs only the task, and the row's status is fixed rather than
resolved from a session (see [Session resolution](#session-resolution)).
**Origin, and which values count as a person.** `manual` is the only origin that
is a person; the other five (`agent_created`, `routine`, `onboarding`,
`automation_run`, `automation_task`, at `models.go:1024-1030`) are not, and each
renders its own marker. The partition is stated rather than inferred because the
enum has six values while the live failed set has only two of them, so a builder
reading the input inventory alone would be guessing about the other four the day
one of them fails. They are not collapsed into a single "automated" word because
an agent creating a task and a schedule creating a task are different triage
facts and the task model already spends six constants distinguishing them. The
set is owned by the tasks system and can grow without this spec changing, so an
unrecognised value renders a generic non-person marker rather than nothing, the
raw stored string, or a broken row.
**Failure reason.** The session's `error_message`, truncated server-side to 512
Unicode code points before it enters the response. The bound stops a 4096-byte
message multiplied by a 200-row page from becoming an 800KB payload for a list the
operator scans. The unit is code points, not bytes, and truncation never splits one,
so the field is valid UTF-8 for every input including one that is not. This is stated
because the measured field is measured in bytes and the obvious Go implementation,
slicing at byte offset 512, produces invalid UTF-8 the first time a non-ASCII message
runs long. Code points cap the worst case at 4 bytes each, still far under the
payload the bound prevents. The client
renders it clamped to one line and never blank: an unresolvable reason becomes
stated fallback copy, per AC-UI-INBOX-FAILED-001.19. Nothing flags a truncated
reason to the client, per AC-UI-INBOX-FAILED-001.19a, because the row is already
clamped to one line and the full text is on the task the row opens.
**Why the reason and not the deck's subtitle.** The deck draws a subsystem label
under each title. That label has no source: `tasks.labels` is empty on every failed
task, and subsystem tags are a plugin-owned, human-assigned dimension the read path
does not have. The failure reason is always present and is the actual triage
content, so it takes the slot.
**The endpoint.** `GET /api/v1/failed-inbox`, taking `workspace_id` (required) and
`limit` (optional), parsed by the same rules the bundle read applies. It takes no
`cursor`, the one query parameter the sibling accepts and this read does not.
**Response envelope: rows, `count`, and an explicit `truncated` boolean.** The
truncation signal is a FIELD OF ITS OWN, and this is the one place the sibling
cannot be copied. The bundle read carries no truncation field at all: it signals
"there is more" solely by emitting `next_cursor`, which the client turns into
`hasMore` and then into the capped `"+"` suffix on the count. That whole chain
hangs off the cursor. Since this read deliberately has none
(`AC-UI-INBOX-FAILED-001.13`), nothing in the sibling's envelope is left to mirror,
and a builder told to "mirror the bundle read" would either re-introduce the cursor
to get the signal back or invent a field silently. So it is named here: `truncated`,
set when the store had more rows than the bound returned, computed the way the
sibling computes `HasMore`. The CLIENT-side presentation
of a capped count is still shared with the sibling per
`AC-UI-INBOX-FAILED-001.17`; it is only the wire signal that differs, and it
differs because the cursor it used to ride on is gone.
**The tab lives in the address as the `tab` query parameter on the existing Inbox
route**, not as a second route path: `?tab=failed` selects Failed, `?tab=needs-you`
and every other value, including an absent, empty, repeated or unrecognised one,
selects Needs you. The destination is one page with two buckets; giving Failed its
own path would make the sidebar entry's active-route match ambiguous and would
duplicate the page shell. Paperclip's `/inbox/<tab>` shape is the right instinct
about bookmarkability and the wrong shape for a route that already exists and is
matched exactly.
Selecting a tab REPLACES the current history entry rather than pushing one. Both
forms are equally bookmarkable, so the only question is what the back control does,
and that follows from what the strip is: two views of one destination. Pushing would
make back walk the operator through their own tab clicks before letting them leave
the Inbox. Replacing means back returns where they came from, and the address still
names the tab for a bookmark or reload.
## Session resolution
The row's failure instant and reason come from one session, chosen by a rule that
must be total because the schema does not enforce what the data currently happens to
satisfy. `task_sessions.is_primary` has no unique index, so more than one primary
session per task is representable, and nothing requires a task to have a session.
Measured 2026-09-14: zero tasks have more than one primary session, zero failed tasks
have no primary session, zero have no session at all. The rule below therefore costs
nothing today and exists so a builder is not forced to invent it, and so a future data
shape cannot silently drop a failed task.
Chosen session, in order: the primary session; if several are marked primary,
the most recently started; if none is, the most recently started session of any
kind; and where two candidates tie on start instant, the lower
`task_sessions.id` compared as an ascending byte-ordered string.
**That last tiebreak is load-bearing, not tidiness.** One of the fields read from
the chosen session is `completed_at`, which
[Ordering, paging, and the failure instant](#ordering-paging-and-the-failure-instant)
makes the primary sort key. Two sessions tying on `started_at` while carrying
different `completed_at` values would put the row in two different positions on two
reads of unchanged data, breaking both the total order and the idempotency
`AC-UI-INBOX-FAILED-001.25` requires. `TaskSession.StartedAt` is a `time.Time` and
not a pointer, so this covers an equal value rather than an absent one.
Where the task has no session, both fields are unresolvable. The row is still listed.
It sorts FIRST and states the failure time is unknown, rather than borrowing
`tasks.updated_at` and presenting a drifted value as the failure time, the mistake
[Ordering, paging, and the failure instant](#ordering-paging-and-the-failure-instant)
exists to prevent. First rather than last is what lets
`AC-UI-INBOX-FAILED-001.30`'s promise survive a bounded page: a row sorted last
is the first row a bound discards, so "sorts last" and "no resolution failure
removes a failed task from the list" could not both hold. First is also right on
the merits, because a task that failed and cannot say when is the row most likely
to need a person.
The status the row renders does not come from this chosen session. It is fixed to
the shared vocabulary's `failed` entry for every row on the tab, per
`AC-UI-INBOX-FAILED-001.20`. `resolveThreadSessionStatus` tests `pending_action`
before it tests `state`, and its `permission` and `clarification` entries both
carry `hasAttention: true`, so resolving status per row from whichever session
this rule picked could put an attention flag on the one bucket whose premise is
that it never carries one. Fixing the status is also why the row model needs no
session-state field, and why the no-session case needs no special handling here.
**How the entry is reached, since it cannot be reached the usual way.** That module
exports two resolvers and no status value: both take a session-shaped argument, and
the `STATUS` table holding the entries is module-private. Fixing the status while
carrying no session field therefore leaves no way to call either resolver honestly.
The row must NOT fabricate a `{state: "FAILED"}` stand-in to pass to one: that
reintroduces exactly the `pending_action`-first precedence
`AC-UI-INBOX-FAILED-001.20a` exists to exclude. The entry is instead made directly
reachable by exporting it from the module that already defines it. Exporting an
existing value adds no entry and defines no second vocabulary, so
`AC-UI-INBOX-FAILED-001.20`'s prohibition is satisfied: it forbids a NEW or
DUPLICATED status, not a wider export of the one that ships. The module is not
reused unchanged: it gains an export.
## Ordering, paging, and the failure instant
**Order: unresolvable-instant first, then failure instant descending, then
`tasks.id` ascending as a byte-ordered string.** Descending because triage reads
newest-first, which is also what the deck draws. `tasks.id` is the final tiebreak
because it is the row's primary key: unique by construction, stable across reads,
and therefore enough to make the order total. An order that is only usually total is
a bug waiting for a fixture.
**That last key names its comparison for the same reason the session tiebreak
does.** `AC-UI-INBOX-FAILED-001.30a` already pins the session-id tiebreak to an
ascending byte-ordered string; leaving the TASK-id tiebreak as bare "ascending"
leaves the comparison to the engine's text collation, the same class of
engine-dependent behaviour the null-placement key below exists to stamp out. A
locale- or case-sensitivity-dependent collation would order two rows differently on
SQLite and PostgreSQL while both satisfied the words "task identifier ascending",
and `AC-UI-INBOX-FAILED-001.10a` requires one order under every engine. The tiebreak
that makes the order total cannot itself be the key that varies.
**The first key is explicit, and must not be left to the engine.** A row whose
failure instant is unresolvable sorts ahead of every resolved row, and that
placement is written as its own leading sort key rather than inherited from how
the store happens to place absent values. This matters because the product runs
two engines and they disagree: under `ORDER BY ... DESC`, SQLite places absent
values last and PostgreSQL places them first. An ordering that relies on the
default is therefore reproducible on one engine and not the other, which
`AC-UI-INBOX-FAILED-001.10`'s totality claim forbids outright. There is no
precedent to copy here: the backend contains no `NULLS FIRST` or `NULLS LAST`
anywhere today, because this is the first ordering key in it that can be absent.
The dual-dialect coverage that already exists for this layer
(`apps/backend/internal/db/dialect/` and the `*_postgres_test.go` twins under
`apps/backend/internal/task/repository/sqlite/`) is where the order gets asserted
on both, rather than on whichever one the developer ran.
**The failure instant is the chosen session's `completed_at`.** Not
`tasks.updated_at`, which the input inventory shows drifting 6.2 days past the real
failure on a live row. A row saying "failed 6 days ago" when it failed 12 days ago is
a quiet lie in the one field the operator uses to decide whether to care.
**Paging: default 50, cap 200**, the same bound the bundle read uses. Reusing the
bound rather than inventing a second one keeps AC-UI-INBOX-FAILED-001.17's capped
presentation identical across both tabs. The truncation SIGNAL is not shared; see
[Data and contracts](#data-and-contracts) for why this read carries its own
`truncated` field instead.
**The bound is the only thing that may elide a row, and it says so.** Two ACs made
absolute-sounding promises a bounded page can break: `AC-UI-INBOX-FAILED-001.5`
lists "exactly those tasks ... and no others", and `AC-UI-INBOX-FAILED-001.30`
promises no resolution failure removes a failed task. Both are now subject to this
bound, because at 201 qualifying tasks the first is arithmetically false, and a
workspace with over 200 unresolvable instants pushes some past the bound despite
sorting first. That is not a flaw in sorting them first: first-of-N is still
discarded when N exceeds the bound, and no ordering fixes it. What sorting first
DOES guarantee is that unresolvable rows are the LAST thing the bound reaches, which
is why the key leads. The honest contract: the bound is the only force that elides a
row, and `truncated` discloses every time it did.
**One page, and no cursor at all.** The read takes no cursor and returns none.
This is a decision, recorded in the requirements' `## Out of scope`, not an
omission: the truncation indicator is the whole of what this read says about rows
past the bound. Why it is left out rather than
finished: [Out-of-scope reasoning](#out-of-scope-reasoning).
**Where the count comes from.** The tab's count is the length of the row array in the
same response, never a separately computed total. That makes
`AC-UI-INBOX-FAILED-001.16`'s "no rendered state exists in which the two disagree"
true by construction rather than by discipline: there is one number and the rows are
it. The sidebar badge's count is produced separately by the bundle read, untouched
here.
## Count separation
The badge and the tab count are produced by different reads and stored under
different per-workspace keys. The separation that matters is a WRITE separation,
and stating it as a write rule is what keeps it both strict and satisfiable: no
failed-bucket code path writes the Needs-you slice, and no Needs-you code path
writes the failed slice.
Reading across the two slices is NOT forbidden, and one place needs it: the Needs
you empty state reads the failed slice's count for
`AC-UI-INBOX-FAILED-001.23`. Tightening this to forbid READS looks like extra safety
and is a contradiction: it forbids the only honest source for a fact another AC
demands. The invariant worth
protecting is that no failed task can ADD TO the badge's number, which is a property
of who WRITES the badge's key. A read cannot inflate a counter.
The Needs-you empty state gains one clause naming failed tasks among what it does
not count, and pointing at the tab. That clause is conditional on the workspace
actually holding a failed task: a workspace with none must not be told that some
exist, which is the same rule the hidden-bundle disclosure already follows.
## Control flow
The failed bucket is read on exactly these triggers and no others: Inbox mount,
tab selection changing,
active workspace changing, the browser tab returning to visible, and a periodic
re-read every 60 seconds while the browser tab is visible and a workspace is active.
Every one of them fires REGARDLESS of which tab is currently selected.
**The 60 seconds is a maximum staleness, not a rate limit.** It is the longest a
visible Failed tab may go without re-reading, which is what makes
`AC-UI-INBOX-FAILED-001.8`'s convergence and `AC-UI-INBOX-FAILED-001.23`'s
present-tense clause bounded rather than merely eventual. A ceiling on request
FREQUENCY would not do that: "at most once every 60 seconds" is equally satisfied by
reading once an hour, or once at mount and never again. A hidden tab is exempt while
hidden and re-reads AT ONCE on returning, not at the next tick, so the exemption
cannot turn an hour in the background into an hour of stale rows. One read per
return, not one per browser event; a return commonly fires several.
It is never read outside the Inbox destination, so it does NOT subscribe to the
Needs-you count slice's five refresh triggers: `needs-you-inbox-02.md` makes those
slice-owned and app-wide precisely so the badge stays right while the Inbox is
closed, so reusing them would contradict the clause above and would also put
Needs-you-owned machinery in charge of failed-slice writes, which
[Count separation](#count-separation) forbids. What that severs is the SUBSCRIPTION,
not the capability: the visibility trigger above is this bucket's own, mounted with
the Inbox, and the browser-visibility hook it uses is generic and already shared by
unrelated consumers, so it carries no Needs-you machinery. This bucket runs only
while the Inbox is open because nothing outside the Inbox renders its count.
**Why it does not stop when Needs you is selected.** The read follows the
DESTINATION, not the tab, because two things visible from Needs you depend on it. The
TAB STRIP carries the Failed count from both tabs, so a tab-scoped read leaves that
count absent or arbitrarily stale the whole time the operator is on Needs you. And
`AC-UI-INBOX-FAILED-001.23` requires the Needs you EMPTY STATE to say whether the
workspace holds a failed task; without the read it has no way to know, and the only
other source is the badge producer, which `AC-UI-INBOX-FAILED-001.14` puts off
limits. The cost is one extra bounded read-only request per Inbox open for an
operator who never opens the Failed tab.
Reads are side-effect free and idempotent. Overlapping reads are resolved by the
same generation guard the Needs-you slice uses: each read issues a generation, and
a response whose generation is no longer current is dropped rather than applied.
The guard is keyed on the WORKSPACE and the generation, and deliberately NOT on the
selected tab. Keying it on the tab would discard exactly the responses the two
points above depend on, since those arrive while Needs you is selected; a guard
that drops them would reintroduce the stale count it was supposed to prevent. The
workspace remains part of the key because a response for a workspace the operator
has already left must never be applied, which is what
`AC-UI-INBOX-FAILED-001.26` requires.
## Failure and recovery
- **A failed-bucket read fails.** The Failed tab renders its error state, carries no
count, and does not claim nothing failed. The Needs you tab's rows, count and state
are untouched, owned by a different read that did not fail. Its EMPTY STATE is the
one place the two meet: `AC-UI-INBOX-FAILED-001.23`'s clause needs a failed count
that no longer exists, so the clause is OMITTED rather than asserting presence or
absence. Omitting is the honest option: claiming none exist is a lie the operator
would act on. Recovery needs no operator action and no retry affordance: the
periodic trigger re-reads within 60 seconds, and a return to visibility re-reads at
once, so the error state is bounded by the same guarantee the rows are.
- **A bundle read fails while Failed is selected.** The reverse holds. The
sidebar badge suppresses per the existing rule; the Failed tab keeps rendering.
- **A read that fails after a successful one clears the rows it was replacing**,
on the same rule and for the same reason the Inbox already applies: rows
surviving under an absent count is the disagreement the count contract forbids.
- **A task changes state during a read.** The read returns either the row or no
row. Convergence is by re-reading, never by an event: the row set must be
correct even for an exit that emits nothing.
- **A row's task is archived or deleted between read and click.** Opening it behaves
as opening any stale task reference already does; no special handling, no
optimistic removal.
## Security
The same rule the Inbox read already applies, through the same authorization call:
workspace scope is resolved server-side from the request identity, and a workspace
outside the caller's visible set produces the non-disclosing not-found outcome rather
than an empty success. The required scope is the workspace reach scope itself, so as
with the bundle read the forbidden outcome is unreachable by construction rather than
unimplemented. Where authentication is disabled, the single-user identity reaches
every workspace by identity resolution, not a bypass.
No sidecar, no per-user state, and no writes at all, so there is nothing here
that a second caller can race into.
## Out-of-scope reasoning
The requirements list the exclusions; the argument lives here, so the contract file
stays under its size ceiling.
- **Pagination.** Measured 2026-09-14 the instance held 15 failed tasks, 14 of them
in one workspace, so the default bound of 50 is not reached and the 200 cap is
more than an order of magnitude away. A cursor here is also harder than the
sibling's: `encodeInboxCursor` is keyed on `(created_at, pending_id)` where
`created_at` is never null, while this sort key is deliberately nullable, so its
cursor would have to encode and compare the unresolvable-instant case across a page
boundary, precisely where a row gets silently duplicated or dropped. Retry, move and
archive all remove rows under AC-UI-INBOX-FAILED-001.8, so the bucket drains by
being worked rather than scrolled.
- **Dismiss, snooze, restore.** Needs you needs per-user hiding because an
unanswered question has no exit but an answer. A failed task already has three
real exits. A fourth, cosmetic one would add a sidecar table, a hidden-count
disclosure and a restore affordance to hide something the operator can resolve.
- **Severity or grouping.** Grouping repeated identical failures is a real design
problem and belongs to a follow-up with its own evidence. A severity signal on an
uncounted bucket would also reintroduce urgency through the side door: the
Paperclip departure noted under [Prior art](#prior-art).
- **Retention.** Measured 2026-09-14, 42 archived tasks carried a failed primary
session, all excluded by AC-UI-INBOX-FAILED-001.7. Nothing here decides when the
unarchived ones age out.
## Copy and coverage
New copy: two tab labels, the failed empty state, the failed error state, the
relative-failure-time phrasing, six origin markers (one per non-person origin
value, plus the generic marker for an origin this capability does not enumerate),
the unknown-failure-time phrasing, the unresolvable reason fallback, and one added
clause in the Needs you empty state.
**No new visual vocabulary.** The row's failure marker is the shared state-icon
module's TASK-state failed pairing; the tab counts are secondary-variant badges in
the tab controls. Neither is a fresh choice. The icon module carries a DIFFERENT
failed pairing under task state and under session state, so "the failed icon" is
ambiguous until the spec picks one: this bucket is defined by task state
(`AC-UI-INBOX-FAILED-001.6`), its row addresses a task, and its status is fixed from
task state, so the task-state pairing is the consistent one. The module's existing
error colour is reused as-is; no hue is added. The badge variant is load-bearing for
a similar reason: the deck chose `variant="line"` specifically BECAUSE a secondary
count badge has contrast on the resulting background and is invisible on the default
variant's track, so shipping `line` without that badge keeps the choice and discards
its justification. All of it goes through `t()`, in five
locales, with no Unicode em dash. The origin markers are six strings rather than
one because `AC-UI-INBOX-FAILED-001.9a` gives each non-person origin its own
marker, so the origin copy is five strings per locale wider than a single
"automated" word would be.
End-to-end coverage asserts the tab strip, a listed failed row with its reason
and relative time, opening the task from the row, and, in the same run, that the
sidebar count did not move when the failed task appeared. That last assertion is
the one that would catch the most likely regression this capability can cause.