fix(server): an ordinary open backlog fills Up next and pad project ready (BUG-3453) (#1854)

A moved-in backlog of open tasks and bugs with no priority set, bugs in
their initial status `new`, answered "No ready items found": only the
literal status `open` counted, and an open item outside an active plan
needed a high/critical priority (BUG-1082's "without flooding", though the
list is capped at three).

Such items now fill the slots the existing tiers leave empty, from a
fallback tier below every item admitted before, so a workspace that
filled three slots gets the same list. The tier admits items whose
collection is not a system collection and declares `priority` or
`severity` (lead-ruled; across every built-in template that is Tasks,
Features, Backlog and Bugs), whose done field is not terminal, that are
not in progress and not blocked. It ranks by priority, else severity,
then age.

`ready`'s help and the MCP `ready` description said it returns the full
actionable backlog; it returns the same capped top three as `next`, and
now says so. The empty state says why and points at `pad project stale`.
ListCollectionsMinimal also returns is_system.

ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-07 08:36:58 -04:00
committed by GitHub
parent 1c23a1bcbc
commit b3cf4f5292
15 changed files with 298 additions and 39 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -390,7 +390,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.
**Tool catalog (v0.69)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.70)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
| Tool | Actions |
|---|---|
@@ -414,7 +414,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.
**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.69"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.70"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
+11 -5
View File
@@ -32,11 +32,14 @@ func readyCmd() *cobra.Command {
return &cobra.Command{
Use: "ready",
Short: "Show actionable next items for an agent",
Long: `List the items that Pad currently considers ready to work on.
Long: `List up to three items Pad suggests working on next, best first.
This is the broader query-oriented counterpart to 'pad project next'. It reuses the
dashboard's suggested-next logic and returns the current actionable backlog
for active plans.`,
It is the same ranked list as 'pad project next' and the dashboard's Up next:
fired reminders, then overdue work, in-progress work, open items in active
plans, and high or critical priority items. Any slot left over is filled with
other open items from collections that track priority or severity (Tasks,
Bugs, Features, Backlog), ranked by priority (else severity), then age.
Blocked items are never listed; 'pad project stale' shows them.`,
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()
@@ -55,7 +58,10 @@ for active plans.`,
}
if len(suggestions) == 0 {
fmt.Println("No ready items found.")
// With the fallback tier (BUG-3453) an empty answer means there
// is genuinely nothing open and unblocked to work on.
fmt.Println("Nothing ready: no open, unblocked items in a collection that tracks priority or severity.")
fmt.Println("Run 'pad project stale' for blocked or stalled work.")
return nil
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -556,7 +556,7 @@
"idempotentHint": true,
"openWorldHint": false
},
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Actionable backlog: items Pad considers ready to work on now.\n The query-oriented counterpart to next (reuses the same\n suggested-next logic, returns the full list).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Up to three items to work on next, best first: the same\n ranked list as next. Reminders, overdue, in-progress, active-\n plan and high/critical items lead; leftover slots are filled\n with other open items from collections that track priority\n or severity. Blocked items are excluded (see stale).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
"inputSchema": {
"properties": {
"action": {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -556,7 +556,7 @@
"idempotentHint": true,
"openWorldHint": false
},
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Actionable backlog: items Pad considers ready to work on now.\n The query-oriented counterpart to next (reuses the same\n suggested-next logic, returns the full list).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Up to three items to work on next, best first: the same\n ranked list as next. Reminders, overdue, in-progress, active-\n plan and high/critical items lead; leftover slots are filled\n with other open items from collections that track priority\n or severity. Blocked items are excluded (see stale).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
"inputSchema": {
"properties": {
"action": {
+5 -3
View File
@@ -70,9 +70,11 @@ Actions:
Required: workspace.
next — Recommended next item to work on (uses dashboard's suggestion logic).
Required: workspace.
ready — Actionable backlog: items Pad considers ready to work on now.
The query-oriented counterpart to next (reuses the same
suggested-next logic, returns the full list).
ready — Up to three items to work on next, best first: the same
ranked list as next. Reminders, overdue, in-progress, active-
plan and high/critical items lead; leftover slots are filled
with other open items from collections that track priority
or severity. Blocked items are excluded (see stale).
Required: workspace.
stale — Items needing attention — stalled, blocked, overdue, or
otherwise falling out of the active workflow.
+2 -2
View File
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
If the user is asking general code questions with no project-management thread, you don't need this server.
## Tool surface (v0.69)
## Tool surface (v0.70)
Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.
@@ -37,7 +37,7 @@ In `relation_targets`, a `multi_relation` key carries a JSON **ARRAY** of the sa
- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.
- `pad_collection` — Collections: list / create / update / delete.
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the actionable backlog and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
- `pad_role` — Agent roles: list / create / update / delete.
- `pad_search` — Full-text search across items: query.
- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`"applied_pending_flush"`, or `"superseded_set_aside"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.
+15 -1
View File
@@ -1738,6 +1738,20 @@ const CmdhelpVersion = "0.1"
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
// as on stdio. Stdio is unchanged.
//
// 0.70 — BUG-3453 (lead ruling). BEHAVIOR bump on the v0.49 grounds
// (a default call's content changed, with no name, enum or param
// moved): `pad_project.action=next` and `ready`, which return the
// dashboard's suggested-next list, now fill any slot the existing tiers
// leave empty with ordinary open items, from collections that are not
// system collections and declare `priority` or `severity`. Open means
// the collection's done field is not terminal and the item is not in
// progress, so an initial status like `new` counts. They rank by
// priority (else severity), then age, below every item admitted before,
// so a workspace that filled three slots gets the same list; one that
// got "nothing ready" for a moved-in backlog now gets it. The `ready`
// description is corrected: it is the same capped top three as `next`,
// not the full backlog.
//
// 0.69 — TASK-2314. BEHAVIOR bump on the v0.42 / v0.45 grounds (an
// error CODE a caller receives changed, with no name, enum or param
// moved): on the REMOTE transport, every route that needs a workspace
@@ -1828,7 +1842,7 @@ const CmdhelpVersion = "0.1"
// this surface can receive it; the entry exists so a future action does
// not collapse it to permission_denied. When an action that can reach
// it is added, that addition is the contract change and owns the bump.
const ToolSurfaceVersion = "0.69"
const ToolSurfaceVersion = "0.70"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a
@@ -0,0 +1,144 @@
package server
import (
"net/http"
"testing"
)
// BUG-3453: a moved-in backlog of ordinary open tasks and bugs, with no
// priority set and bugs in their initial status `new`, answered "No ready
// items found" from `pad project ready` and an empty Up next. Only the literal
// status `open` counted, and an open item outside an active plan needed a
// high/critical priority. Such items now fill the slots the tiers above leave
// empty; they never displace an item those tiers admit.
const bug3453BugsSchema = `{"fields":[` +
`{"key":"status","type":"select","options":["new","fixing","fixed"],"terminal_options":["fixed"],"default":"new"},` +
`{"key":"severity","type":"select","options":["low","medium","high","critical"]}]}`
func bug3453Bugs(t *testing.T, srv *Server, slug string) {
t.Helper()
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]any{
"name": "Bugs", "slug": "bugs", "prefix": "BUG", "schema": bug3453BugsSchema,
}); rr.Code != http.StatusCreated {
t.Fatalf("create bugs: %d %s", rr.Code, rr.Body.String())
}
}
func suggestionTitles(resp DashboardResponse) []string {
out := make([]string, 0, len(resp.SuggestedNext))
for _, s := range resp.SuggestedNext {
out = append(out, s.ItemTitle)
}
return out
}
func TestBUG3453_MovedInBacklogIsReady(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
slug := createWSWithCollections(t, srv)
bug3453Bugs(t, srv, slug)
// The repro's backlog: a task given no priority (the Tasks schema
// defaults it to medium) and bugs in their initial status `new` with
// no severity, written down in this order.
createItem(t, srv, slug, "bugs", map[string]any{"title": "Bug one", "fields": `{"status":"new"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "Task one", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "bugs", map[string]any{"title": "Bug two", "fields": `{"status":"new"}`})
resp := getDashboard(t, srv, slug)
got := suggestionTitles(resp)
// The medium-priority task first; the unranked bugs oldest first.
want := []string{"Task one", "Bug one", "Bug two"}
if len(got) != len(want) {
t.Fatalf("suggested_next = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("suggested_next = %v, want %v", got, want)
}
}
for i, r := range []string{"Open task (medium priority)", "Open item", "Open item"} {
if resp.SuggestedNext[i].Reason != r {
t.Errorf("reason[%d] = %q, want %q", i, resp.SuggestedNext[i].Reason, r)
}
}
})
}
func TestBUG3453_FallbackRanksByPriorityOrSeverityThenAge(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
bug3453Bugs(t, srv, slug)
createItem(t, srv, slug, "tasks", map[string]any{"title": "Old low task", "fields": `{"status":"open","priority":"low"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "Medium task", "fields": `{"status":"open","priority":"medium"}`})
createItem(t, srv, slug, "bugs", map[string]any{"title": "High severity bug", "fields": `{"status":"new","severity":"high"}`})
resp := getDashboard(t, srv, slug)
got := suggestionTitles(resp)
// One rank (priority, else severity), then age: the high-severity bug,
// though newest, leads; the old low-priority task trails.
want := []string{"High severity bug", "Medium task", "Old low task"}
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Fatalf("suggested_next = %v, want %v", got, want)
}
if r := resp.SuggestedNext[0].Reason; r != "Open item (high severity)" {
t.Errorf("reason = %q, want %q", r, "Open item (high severity)")
}
}
// The fallback admits only WORKABLE collections (not system, and the schema
// declares priority or severity), and never a done or blocked item.
func TestBUG3453_FallbackExclusions(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
bug3453Bugs(t, srv, slug)
createItem(t, srv, slug, "ideas", map[string]any{"title": "An idea", "fields": `{"status":"new"}`})
createItem(t, srv, slug, "docs", map[string]any{"title": "A doc", "fields": `{"status":"draft"}`})
createItem(t, srv, slug, "conventions", map[string]any{"title": "A convention", "fields": `{"status":"active","priority":"must"}`})
createItem(t, srv, slug, "bugs", map[string]any{"title": "Fixed bug", "fields": `{"status":"fixed"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "Done task", "fields": `{"status":"done"}`})
blocker := createItem(t, srv, slug, "tasks", map[string]any{"title": "Blocker", "fields": `{"status":"open"}`})
blocked := createItem(t, srv, slug, "tasks", map[string]any{"title": "Blocked task", "fields": `{"status":"open"}`})
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+blocker.Slug+"/links", map[string]any{
"target_id": blocked.ID, "link_type": "blocks",
}); rr.Code != http.StatusCreated {
t.Fatalf("block: %d %s", rr.Code, rr.Body.String())
}
got := suggestionTitles(getDashboard(t, srv, slug))
// Only the blocker itself is open, unblocked and workable.
if len(got) != 1 || got[0] != "Blocker" {
t.Fatalf("suggested_next = %v, want [Blocker]", got)
}
}
// A workspace whose tiers already fill the three slots gets the same list:
// the fallback never displaces. The fallback-eligible items are created
// FIRST, so oldest-first ordering would put them on top if it reached them.
//
// It passes on the code before BUG-3453 too, by design: it is the invariant,
// not the fix. What it catches is a fallback that is not sorted last. With
// that comparator removed, the old critical bug takes a slot
// ([In flight, Old critical bug, Critical]) and this fails.
func TestBUG3453_FullListUnchanged(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
bug3453Bugs(t, srv, slug)
createItem(t, srv, slug, "tasks", map[string]any{"title": "Old plain task", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "bugs", map[string]any{"title": "Old critical bug", "fields": `{"status":"new","severity":"critical"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "In flight", "fields": `{"status":"in-progress","priority":"low"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "Critical", "fields": `{"status":"open","priority":"critical"}`})
createItem(t, srv, slug, "tasks", map[string]any{"title": "High", "fields": `{"status":"open","priority":"high"}`})
resp := getDashboard(t, srv, slug)
got := suggestionTitles(resp)
want := []string{"In flight", "Critical", "High"}
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Fatalf("suggested_next = %v, want %v", got, want)
}
wantReasons := []string{"In-progress task (low priority)", "Open task (critical priority)", "Open task (high priority)"}
for i, s := range resp.SuggestedNext {
if s.Reason != wantReasons[i] {
t.Errorf("reason[%d] = %q, want %q", i, s.Reason, wantReasons[i])
}
}
}
+85 -6
View File
@@ -229,6 +229,14 @@ func priorityRank(priority string) int {
// isActiveStatus returns true if the status indicates work actively in progress.
// It excludes both initial/queued states and terminal/completed states.
// itemNumber is the item's workspace-sequential number, 0 when unset.
func itemNumber(it models.Item) int {
if it.ItemNumber == nil {
return 0
}
return *it.ItemNumber
}
func isActiveStatus(status string) bool {
s := strings.ToLower(strings.ReplaceAll(status, "-", "_"))
switch s {
@@ -1006,9 +1014,31 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
// entry and the suggestion would drift.
overdue bool
overdueReason string
// fallback marks the BUG-3453 tier: an ordinary open item outside any
// active plan, admitted only to fill slots the tiers above leave empty.
fallback bool
}
var candidates []suggestion
// workable: the collections whose open items may fill the fallback tier
// (BUG-3453, lead-ruled): not a system collection, and the schema
// declares a `priority` or `severity` field. Across every built-in
// template that is Tasks, Features, Backlog and Bugs; Conventions (a
// system collection with a priority field), Ideas, Docs, Plans and the
// people templates' collections stay out. No collection is named.
workable := make(map[string]bool, len(collections))
for _, c := range collections {
if c.IsSystem {
continue
}
for _, f := range ctxMap[c.ID].schema.Fields {
if f.Key == "priority" || f.Key == "severity" {
workable[c.ID] = true
break
}
}
}
for _, dp := range resp.ActivePlans {
// Reuse the children batched once above (BUG-2002) instead of
// re-resolving the plan slug and re-querying its children per plan.
@@ -1098,11 +1128,41 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
taskStatus := extractFieldValue(item.Fields, "status")
isInProgress := isActiveStatus(taskStatus)
isOpen := taskStatus == "open"
if !isInProgress && !isOpen {
continue
}
pri := extractFieldValue(item.Fields, "priority")
odField, odValue, isOverdue := itemOverdue(item.Fields, todayStr)
// The tiers below admit only the literal status `open` and, for open
// items, a high/critical priority or a passed deadline. An item they
// turn away can still FILL AN EMPTY SLOT (BUG-3453): a moved-in
// backlog of ordinary open tasks and bugs, with no priority set and
// an initial status like `new`, used to answer "nothing ready".
// It is open when its collection's done field is not terminal and it
// is not in progress; the sort keeps every fallback item below every
// item admitted above, so a workspace that fills its slots today gets
// the same list.
admitted := isInProgress || (isOpen && (isOverdue || pri == "high" || pri == "critical"))
if !admitted {
if !workable[item.CollectionID] || isItemDone(item.Fields, item.CollectionID, ctxMap) {
continue
}
if _, blocked := firstActiveBlocker[item.ID]; blocked {
continue
}
// One rank on the low..critical scale: the priority when set,
// else the severity. A Bugs collection declares only severity,
// so a high-severity bug ranks with high-priority tasks rather
// than below every task that has any priority at all.
rank := priorityRank(pri)
if pri == "" {
rank = priorityRank(extractFieldValue(item.Fields, "severity"))
}
candidates = append(candidates, suggestion{
item: item,
status: taskStatus,
priority: rank,
fallback: true,
})
continue
}
// Open orphans must be high or critical to surface — open
// in-progress items always do (continuing-work signal beats
// priority gating).
@@ -1113,9 +1173,6 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
// where the deadline would quietly stop: a low-priority orphan three
// weeks late would be reported by `stale` and never suggested by
// `next`, which is the exact split GitHub #1010 is about.
if !isInProgress && !isOverdue && pri != "high" && pri != "critical" {
continue
}
if _, blocked := firstActiveBlocker[item.ID]; blocked {
continue
}
@@ -1135,6 +1192,24 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
// "active-plan continuation" suggestion stays at the top when
// both are present. Lower rank = higher priority.
sort.Slice(candidates, func(i, j int) bool {
// The fallback tier (BUG-3453) sits below everything: it only fills
// slots, never displaces. Within it: the rank (priority, else
// severity), then the oldest first, so a moved-in backlog reads in
// the order it was written down.
if candidates[i].fallback != candidates[j].fallback {
return candidates[j].fallback
}
if candidates[i].fallback {
if candidates[i].priority != candidates[j].priority {
return candidates[i].priority < candidates[j].priority
}
if !candidates[i].item.CreatedAt.Equal(candidates[j].item.CreatedAt) {
return candidates[i].item.CreatedAt.Before(candidates[j].item.CreatedAt)
}
// created_at is stored to the second; the item number is the
// workspace's creation sequence and breaks the tie.
return itemNumber(candidates[i].item) < itemNumber(candidates[j].item)
}
// OVERDUE FIRST, above in-progress (IDEA-2641). The list is capped at
// three, so a rank below in-progress would not merely order the
// deadline lower — on any workspace with three things in flight it
@@ -1189,6 +1264,10 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
}
if pri != "" {
reason += " (" + pri + " priority)"
} else if sev := extractFieldValue(c.item.Fields, "severity"); c.fallback && sev != "" {
// Fallback items only: an item the tiers above admit reads exactly
// as it did before BUG-3453.
reason += " (" + sev + " severity)"
}
if c.overdue {
// Prefixed rather than appended: the deadline is why this is at
+11 -8
View File
@@ -891,10 +891,10 @@ func TestDashboardSuggestedNextFromPlannedPlan(t *testing.T) {
}
// TestDashboardSuggestedNextOrphan_InProgressBeatsPriority pins
// BUG-1082's gating rule for the orphan branch: in-progress items
// surface regardless of priority, but open items must be high or
// critical to surface (avoids flooding suggestions with low-priority
// open work). The "continue what's in flight" signal beats priority.
// BUG-1082's ranking for the orphan branch: in-progress items surface
// regardless of priority, and rank above open work. A low-priority open
// orphan used to be excluded outright; since BUG-3453 it fills an empty slot
// BELOW the in-progress one, so "continue what's in flight" still leads.
func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
t.Parallel()
srv := testServer(t)
@@ -905,7 +905,7 @@ func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
"title": "Low-Pri In-Progress Orphan",
"fields": `{"status":"in-progress","priority":"low"}`,
})
// Low-priority OPEN orphan should NOT surface.
// Low-priority OPEN orphan fills the next slot, below it (BUG-3453).
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Low-Pri Open Orphan",
"fields": `{"status":"open","priority":"low"}`,
@@ -913,12 +913,15 @@ func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
resp := getDashboard(t, srv, slug)
if len(resp.SuggestedNext) != 1 {
t.Fatalf("expected exactly 1 suggested_next (in-progress only), got %d: %+v",
if len(resp.SuggestedNext) != 2 {
t.Fatalf("expected 2 suggested_next (in-progress, then the open fallback), got %d: %+v",
len(resp.SuggestedNext), resp.SuggestedNext)
}
if got := resp.SuggestedNext[0].ItemTitle; got != "Low-Pri In-Progress Orphan" {
t.Errorf("expected in-progress orphan, got %q", got)
t.Errorf("expected in-progress orphan first, got %q", got)
}
if got := resp.SuggestedNext[1].ItemTitle; got != "Low-Pri Open Orphan" {
t.Errorf("expected the open orphan second, got %q", got)
}
}
+10
View File
@@ -166,6 +166,16 @@ func TestFutureDeadlineIsNotOverdue(t *testing.T) {
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Plenty of time", "fields": `{"status":"open","priority":"low","due_date":"2099-12-31"}`,
})
// Three high-priority tasks fill every slot. Since BUG-3453 an ordinary
// open item fills an EMPTY slot from a fallback tier below them, so the
// item's absence only discriminates when no slot is empty: an
// unconditional bypass would rank it FIRST (overdue leads the list),
// the fallback cannot reach it.
for _, title := range []string{"High one", "High two", "High three"} {
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": title, "fields": `{"status":"open","priority":"high"}`,
})
}
resp := getDashboard(t, srv, slug)
if got := len(filterAttention(resp.Attention, "overdue")); got != 0 {
+4 -3
View File
@@ -349,14 +349,15 @@ func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collectio
// ListCollectionsMinimal returns collection rows populated with just the
// fields needed for done-detection context and slug lookups: ID, Slug,
// Schema, Settings. Skips the per-collection COUNT queries that
// Schema, Settings, IsSystem (the dashboard's ready fallback keeps system
// collections out, BUG-3453). Skips the per-collection COUNT queries that
// ListCollections runs, which matters on hot paths that only need those
// fields (e.g. handlers that build a ctxMap for isItemDone or an ID→slug
// map). Includes soft-deleted collections so items still attached to them
// can be evaluated.
func (s *Store) ListCollectionsMinimal(workspaceID string) ([]models.Collection, error) {
rows, err := s.db.Query(
s.q(`SELECT id, slug, schema, settings FROM collections WHERE workspace_id = ?`),
s.q(`SELECT id, slug, schema, settings, is_system FROM collections WHERE workspace_id = ?`),
workspaceID,
)
if err != nil {
@@ -366,7 +367,7 @@ func (s *Store) ListCollectionsMinimal(workspaceID string) ([]models.Collection,
var result []models.Collection
for rows.Next() {
var c models.Collection
if err := rows.Scan(&c.ID, &c.Slug, &c.Schema, &c.Settings); err != nil {
if err := rows.Scan(&c.ID, &c.Slug, &c.Schema, &c.Settings, &c.IsSystem); err != nil {
return nil, fmt.Errorf("scan collection minimal: %w", err)
}
result = append(result, c)