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:
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user