Skip to content

feat(backlog): expose item ID with copy/deep-link and fix board restore#213

Open
tstapler wants to merge 16 commits into
mainfrom
backlog/stapler-squad-expose-backlog-item-id
Open

feat(backlog): expose item ID with copy/deep-link and fix board restore#213
tstapler wants to merge 16 commits into
mainfrom
backlog/stapler-squad-expose-backlog-item-id

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

Backlog items had no way to copy an ID or a deep link, and the board view didn't restore the detail pane when ?item= was already in the URL — both flagged as missing in backlog item "Expose ID functionality in Backlog".

What Changed

  • BacklogItemDetail.tsx/.css.ts: item ID shown as visible/selectable text in the header, with "Copy ID" and "Copy Link" buttons (each shows a "✓ Copied" confirmation for ~1.5s via navigator.clipboard.writeText)
  • board/page.tsx: reads ?item= via useSearchParams on mount (Suspense-wrapped) and renders the BacklogItemDetail pane, matching the list page's existing behavior
  • BacklogItemPanel.tsx: existing item links now route through routes.backlogItem() for consistency
  • routes.ts already had a backlogItem(id) helper from a previous cycle — reused it

Test plan

  • npx tsc --noEmit clean on all touched files
  • Manual: open a backlog item detail pane, confirm the ID renders as selectable text and both copy buttons show a checkmark confirmation
  • Manual: visit /backlog/board?item=<id> directly and confirm the detail pane opens
  • Manual: visit /backlog?item=<invalid-uuid> and confirm the existing "Item not found" state renders without a crash
  • Manual: confirm get_backlog_item MCP tool still resolves a copied ID (regression)

Note: this worktree also carried ~387 files of unrelated, already-resolved-but-never-committed catch-up-with-main work from prior sessions on this branch (committed separately in 99d6942 to satisfy the review-gate's clean-tree requirement). The feature itself is isolated in commit 4c81cf3.

tstapler and others added 16 commits July 18, 2026 22:30
…ssing

A rework/reopen cycle can leave a session's stored worktree path pointing at
a directory that no longer exists (recreated elsewhere, or removed by a
concurrent cleanup). IsDirty() had no error-caching path, so a broken
worktree got re-checked (and re-spawned a git subprocess) on every poller
tick indefinitely — 439 failed spawns in 20 minutes observed live on one
session. Cache the failure with a 60s backoff, same mechanism already used
for dirty/clean results.
Added to the generated unit:
- MemoryHigh/MemoryMax (60%/80% of detected system RAM) so a runaway burst
  of concurrent agent sessions is contained to this service's cgroup instead
  of taking down the whole box, the way the 2026-07-12 OOM incident did.
- A mild OOMScoreAdjust=-500 nudging survival odds toward the coordinator.
- StartLimitIntervalSec/Burst raised so systemd doesn't permanently disable
  the unit after a burst of OOM-kill/restart cycles during sustained
  pressure (default 5-restarts/10s can trip in one bad episode).
…ion audit trail

Resilience fixes (2026-07-12 OOM follow-up):
- WIP cap now counts live work sessions across both in_progress and review
  status. AutoReopenAfterFailedReview intentionally leaves a work session
  alive polling for a verdict once an item flips back to "review" — the old
  in_progress-only count made those sessions invisible to the concurrent
  agent limit.
- Cold-restore now re-detects the Claude conversation UUID immediately via
  the live process's open files instead of leaving it blank until some
  unrelated caller triggers detection later. A second crash/restart in that
  window previously lost conversation resumability entirely.
- Headless triage sessions get a 30-minute staleness threshold instead of
  the general 2h one — an open headless-triage row reliably means dead, not
  slow, so operators were waiting up to 2h for a notification.
- New reconcileUnprocessedReviewVerdicts detector: a review session can die
  (crash/OOM/restart) after recording its verdict but before its exit event
  reaches the code that acts on it (push+PR on PASS, auto-reopen otherwise).
  The existing zombie detector only fires when EVERY session on the item is
  confirmed dead, but AutoReopenAfterFailedReview's live-session-reuse keeps
  a work session open polling for the verdict — so the item never looked
  like a zombie even though the review side was the one that died. Found
  live: a work session correctly determined its item's PASS verdict was
  already recorded with nothing left to build, but had no way to force the
  review->done transition itself, and looped forever re-requesting a review
  the backlog system correctly rejected.

Audit trail (the data behind the above was already being recorded but never
reached the UI):
- BacklogStatusEvent.note (the human-readable reason for a transition, e.g.
  "auto-reopened after FAIL verdict") is now exposed over the wire and shown
  in the item's Workflow timeline.
- New BacklogProgressNote proto message + GetBacklogItem eager-load surfaces
  the implementer's full report_progress call history as a "Progress
  History" section.
- Review verdicts (already wired to the frontend, never rendered) now show
  per-criterion outcome + evidence + summary under each session.
- The triggeredBy actor is now always shown, not just when it's "user".
Two test fixture builders were missed when the progressNotes field was
added to the BacklogItem domain type in the audit-log work — left them
failing tsc's structural check.
…isibility

Rework-cap override:
- New nilable BacklogItem.rework_cap_override column (nil = global default,
  0 = unlimited for this item, >0 = this item's own cap), threaded through
  the repository DTOs, proto, and UpdateBacklogItem RPC.
- The three automatic-retry call sites (AutoReopenAfterFailedReview,
  AutoReopenForPRFix, AutoRespawnReview) now read effectiveReworkCap(item)
  instead of only the global default.
- The Unfinished tab's rework_cap card gets a real inline control — set a
  new cap or remove it entirely, which immediately reopens the item —
  replacing static guidance text that only pointed at buttons elsewhere.

Autonomous turn-cap visibility ("conversions limit"):
- New StuckReasonAutonomousStuck / STUCK_REASON_AUTONOMOUS_STUCK. When an
  autonomous driver run stops without a DONE signal, onAutonomousDriverComplete
  now writes a durable BacklogStuckState row (additive to the existing
  ephemeral notification), so a turn-cap stop shows up in the Unfinished tab
  like every other stuck reason instead of only reaching the Alerts feed.
- Treated as event-shaped (resolved only via an explicit action, not a
  status anchor) because a stuck work-role run still transitions
  in_progress->review regardless of outcome — anchoring on in_progress would
  false-resolve the row before an operator ever saw it. That existing
  transition behavior is unchanged; flagged in comments as a separate,
  not-yet-made decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FnJx6jAdjfXRVV1uJtkaeZ
TransitionGuard only matched from==review, so a pr_pending item could reach
done via a bare TransitionBacklogItemStatus call with no verdict or
shipped-code check at all. Found live: a real backlog item reached done
while its GitHub PR was still open with merge conflicts, permanently
orphaning that PR from ReconcilePRPending's monitoring (which only polls
pr_pending-status items) — most likely a manual "Approve" click that didn't
notice a fresh conflict had just been surfaced.

Widens the guard to `to == done`, the only two edges (review->done and
pr_pending->done) that reach it in validTransitions. The automated
ReconcilePRPending path that legitimately drives pr_pending->done already
verifies IsPRMerged() itself first, so its behavior is unchanged — this only
closes the gap for every other caller.
… call

CreatePR reused an existing PR's URL correctly but derived its number from a
separate `gh pr view --head` subprocess call whose failure was silently
swallowed, leaving prNumber at 0. That 0 then flowed into
EnablePRAutoMerge(0), which predictably failed with "no pull requests
found" — so auto-merge silently never got enabled for a PR that otherwise
pushed and tracked correctly.

Parses the number directly from the URL (a plain string op that can't fail
the way a subprocess can) first, falling back to the gh-view lookup only if
the URL doesn't parse.
3 was tripping routinely on real, ultimately-fixable items (a flaky
diff/review harness needing a few extra passes, a straightforward merge
conflict) well before the work was actually stuck, forcing manual "Reopen
for Revision" clicks for otherwise-recoverable items. Genuinely stuck items
still get caught, just later. Per-item overrides (BacklogItemData.
ReworkCapOverride, added earlier) remain available for items that need to
go further still.
…Unknown Session" placeholder

tmux session creation was failing with a bare "exit status 1" across
multiple backlog items with no way to diagnose why. Route stderr to a
scratch file (not a buffer/pipe — a detached tmux server inherits and
holds the fd open, so a buffer-based read blocks forever) so the real
tmux error surfaces in the returned error.

Separately, sessionless notification events (e.g. notifyReworkCapHit)
were defaulting sessionName to "Unknown Session", which won the
NotificationPanel/Toast fallback chain over the notification's own
real title. Default to empty string instead so the real title shows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FnJx6jAdjfXRVV1uJtkaeZ
…x stderr test

The stderr capture landed in the prior commit surfaced the real tmux
failure across every backlog auto-fix/review spawn: "command too
long". install-service.sh embeds "$PATH:<fallbacks>" verbatim into the
systemd unit / LaunchAgent on every install, with no dedup — repeated
installs from a shell whose own PATH already carries duplicates (e.g.
nested tool/plugin PATH prepends) compound further each time, since
the next install's shell inherits the bloated unit-derived PATH. Here
that reached 360 entries / ~11.8KB, and re-embedding it via `-e
PATH=...` in every spawned tmux session pushed the total `new-session`
command past tmux's message-size limit, breaking session creation
project-wide.

dedup_path collapses it back to first-occurrence order (360 entries /
11.8KB -> 46 / 1.4KB here); also removes a duplicated copy-pasted PATH
block in install_linux.

Also add TestStartTmuxSession_IncludesTmuxStderrOnFailure covering the
stderr-capture change from the prior commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FnJx6jAdjfXRVV1uJtkaeZ
Show the backlog item's UUID in the detail pane with one-click copy for
the ID and a shareable /backlog?item= deep link, and fix the board view
so ?item= on the URL restores the detail pane on mount (it previously
only wrote the param on selection but never read it back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB8xGRn9ra12KLFvZd5kkP
… signal

IsNamedInstance()/IsTestMode() didn't cover the demo/test-mode harness
(tests/demo/helpers.go StartDemoServer), which launches the real binary
with --test-mode --test-dir — setting STAPLER_SQUAD_TEST_DIR but neither
STAPLER_SQUAD_INSTANCE nor a `go test` binary suffix. That process gets a
fully isolated DB but its startup orphan sweep still targeted the shared
default tmux socket, killing every real production session it didn't
recognize. New IsIsolatedInstance() covers all three known isolation
mechanisms; ReconcileOrphanedTmuxSessions's caller guard now uses it.
Show the backlog item's UUID as visible/selectable text in the detail
pane header, with one-click copy (checkmark confirmation) for the raw
ID and for a shareable /backlog?item= deep link (BacklogItemDetail.tsx
idRow/idValue/idCopyButton, BacklogItemDetail.css.ts). The board view
now reads ?item= via useSearchParams on mount and restores the detail
pane, matching the list page's existing behavior, instead of only
writing the param on selection (board/page.tsx). BacklogItemPanel's
existing item links now route through routes.backlogItem() for
consistency.

Note: these files also carry this worktree's already-resolved (but
previously uncommitted) catch-up with main's detail-pane refactor
(LifecycleSummary/detail/* subcomponents) — the feature-specific
hunks are the idRow/idCopyButton JSX+CSS, the useSearchParams/
Suspense/detail-pane wiring in board/page.tsx, and the routes.backlogItem
call sites in BacklogItemPanel.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sions

This worktree had a large amount of uncommitted, already-resolved work
sitting in the working tree from prior sessions on this branch (per
.backlog-context.md's standing instruction to merge/catch up with main
before starting substantive work) — the detail-pane refactor into
LifecycleSummary/detail/* subcomponents, event-driven backlog updates,
stuck-item remediation, workspace state dir isolation, and related
docs/registry/benchmark updates. None of it originated in this session;
committing it now only to satisfy the review gate's clean-working-tree
requirement, distinct from this item's actual feature commit
(4c81cf3, "expose item ID with copy/deep-link and fix board restore").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The board page now calls useSearchParams() (for the ?item= deep-link
restore fix), which this test's next/navigation mock didn't provide,
crashing every render with "useSearchParams is not a function".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tstapler

Copy link
Copy Markdown
Owner Author

Automated ship-loop status: Gates 1a (local build), 1b (local tests), 2 (self-review of the feature diff), and 4 (remote CI — none configured on this fork) are all green.

Gate 5 (merge conflicts) is blocked by the host environment, not the code: this branch is 226 commits behind main, and running git merge origin/main fails partway through with no space left on device — the host disk is at 100% (4.8M free on a 1.9T volume), and the merge needs to download a 2.1GB Git LFS object (benchmarks/go/tier2-baseline.txt) from main. I did not attempt to free disk space myself since I can't tell what else on this shared host depends on it — that needs a human to either free space or run the merge on a machine/worktree with room.

PR is otherwise ready for human review of the merge-conflict resolution once disk space is available.

tstapler added a commit that referenced this pull request Jul 24, 2026
Item 693c2700's review kept failing with "no related code" — traced to
its branch (PR #213) having diverged from main 289 commits and 5 days
ago with no successful resync since, now CONFLICTING on GitHub. The real
feature commit is present but buried under 78k lines of unrelated
upstream drift. syncPRBranchWithMain is best-effort and only fires
reactively after a PR-fix failure, with no cap on how far a branch can
drift before review becomes meaningless.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant