Skip to content

feat(titles): terse 'keyword -> gist' titles, keep user first-line hints, CAS name updates#27

Merged
miguelrisero merged 6 commits into
mainfrom
feat/short-arrow-titles
Jul 8, 2026
Merged

feat(titles): terse 'keyword -> gist' titles, keep user first-line hints, CAS name updates#27
miguelrisero merged 6 commits into
mainfrom
feat/short-arrow-titles

Conversation

@miguelrisero

@miguelrisero miguelrisero commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Problem

Workspace titles were generated with a hardcoded PR-title prompt ("imperative summary, at most 6 words") — exactly the "Fix bug where..." style we do not want. Worse, the user's deliberate first-line title was destroyed: the frontend always sends the prompt's first line as the workspace name, and the background Haiku job then unconditionally overwrote it (no compare-and-set on name, unlike the branch which already had one). Manual renames during the ~5-20s Haiku window and MCP-provided names were clobbered too.

What changed (backend only)

  • Terse keyword -> gist titles. Replaced the prompt in crates/executors/src/title_gen.rs with one that asks for <keyword> -> <gist> (2-5 word gist, 40-char cap, lowercase except proper nouns, terse noun fragments), with Miguel's good/bad examples and a first-line-reuse rule. TITLE_MAX_CHARS 72 -> 48; unicode normalized to ->; trailing punctuation trimmed after the char cap.
  • User first-line hints win. New pure first_line_title_hint(): a first line containing -> always wins; a short first line (<=8 words, <=48 chars) with a body also wins; trailing : / , / ... rejects. When a hint exists we skip the Haiku call entirely — keep the hint verbatim as the name and derive the branch deterministically from it (instant, no model latency).
  • No more clobbering. New Workspace::update_name_if_unchanged CAS (NULL-safe SQLite IS, modeled on the existing update_branch_name_if_unchanged) replaces the unconditional Workspace::update. expected_old is the pre-spawn workspace-row snapshot name, so a manual rename or an MCP-provided name that lands in the window survives. The MCP start_workspace path is protected by this CAS alone.
  • Branch safety preserved. Titles can now contain -> but never reach a branch name raw — both paths route through slugify / git_branch_id_with_len, pinned by new unit tests ("bp -> runflow dogfood" -> "bp-runflow-dogfood").

How verified

  • cargo test -p executors -p utils — 87 tests pass, including the new first_line_title_hint accept/reject matrix, arrow-in-title / 48-char-cap / trailing-period parse tests, and the slugify/git_branch_id_with_len arrow-degradation tests.
  • cargo check -p db -p server compiles clean with SQLX_OFFLINE=true; pnpm run prepare-db regenerated the offline metadata for the new query (committed under crates/db/.sqlx).
  • cargo fmt applied to touched crates.
  • Prompt smoke test: ran the new prompt through claude-code --model haiku — output parsed as JSON and matched keyword -> gist under 40 chars ({"title":"email -> bp off customer.io", ...}).
  • NOT verified: full end-to-end workspace creation in a running app (dev server), and the live manual-rename race — logic is covered by unit tests + the CAS but not exercised against a running backend.

Risks

  • The short-first-line-with-body heuristic will keep a user's imperative first line ("Fix mobile scroll" + body) verbatim instead of restyling to keyword -> — this matches the "user hints win" decision; drop the short_enough && has_body clause if restyle-always-except-arrow is wanted.
  • Backend-only change; no TS type regen, no frontend edits. Deployed instance needs the usual rebuild+restart to take effect.

ship-it battery

Full review battery run on this PR (base commit 519cb948, final commit f6a1ea02).

Council round 1 (mixed engine: 4 Codex personas + 1 Claude cross-check seat, all completed). No P0. Applied: reject overlong arrow first lines (>100 chars) as title hints so a pasted log line containing -> can never be stored verbatim as a workspace name (a972bf73). Rejected with reasons: MCP explicit-name suppression (locked decision: CAS-only protection; name.is_some() cannot discriminate user intent since the frontend always sends it — flagged for a future provenance field); coupling the branch rename to the name-CAS outcome (independent CAS semantics by design, pre-dating this PR); arrow+trailing-colon acceptance and prompt rewording (heuristic and prompt text are locked as specified); git-refs-before-DB-CAS TOCTOU inside rename_workspace_branch (pre-existing shared infra from #17, unchanged here — noted as known debt: a background rename can make a concurrently issued manual branch rename fail with a visible error).

Simplify pass (4 parallel cleanup agents: reuse/simplification/efficiency/altitude). Applied: hoist duplicate char count, drop a no-op trim (f77c9dca); folded both title paths into one canonicalize_title() so the hint and Haiku paths can never drift in stored form (e036d514). Skipped with reasons: replacing the pre-existing local slugify with git_branch_id_with_len (drive-by refactor outside the diff; the dual-layer test pinning is deliberate defense in depth).

Codex + xhigh review (Codex CLI xhigh background review + 5 parallel correctness finder angles + conventions check). Applied (eacbdd11): strip dangling arrow fragments after the 48-char cap; ATX-only heading strip so #27 -> x keeps its hash; unicode ellipsis rejects like ...; unicode arrows normalized before the hint length check; skip the generated branch rename when the seed slugs to empty (all-CJK/emoji/punctuation hints kept as the name, heuristic branch preserved) — each pinned by unit tests.

Council round 2 (mixed, on the updated diff): unanimous APPROVE, zero P0/P1 across all 5 seats. The Claude seat additionally fuzz-verified the fixes (200k hostile inputs: no panics, loop-termination proof, byte-slice boundary proof, ends_with('-') guard exactness) and surfaced one cosmetic P2 — the tail-trim was single-pass; fixed with a fixpoint loop (f6a1ea02).

CodeRabbit: substituted. The CodeRabbit CLI fails deterministically in this non-interactive environment ("Non-interactive environment detected. Use --api-key"); per the run conventions a Codex xhigh backstop reviewed the final diff instead. It reported no P0/P1; its two P2s (hint length measured before whitespace collapse; 7+-hash runs stripped as headings contrary to CommonMark) are fixed in f6a1ea02. Zero actionable findings remain.

Verification: cargo test -p executors -p utils -p db green after every fix batch (91 executors + 4 utils tests at HEAD, 24 of them for title_gen/hint edge cases); cargo clippy clean on touched crates; cargo fmt applied; no new SQLx queries beyond the round-0 .sqlx entry (already committed). Not verified live: end-to-end workspace creation against a running backend (unchanged from the PR's original honest caveat).

A pasted line containing ' -> ' (e.g. a log line) could be stored verbatim
as the workspace name with no length bound. Arrow hints now only win at a
plausible title length (<= 100 chars, matching the frontend's first-line
name cap); longer lines fall through to the model path, which caps titles
at 48 chars.
Simplify pass: compute the first line's char count once instead of two
O(n) scans, and remove the no-op .trim() — split_whitespace/join leaves
no leading whitespace and trim_end_matches already covers the trailing
space a mid-cut can leave.
…odel paths

The hint and Haiku paths normalized candidate titles with drifting rules
(hint trimmed only trailing periods and kept raw whitespace; the model
path collapsed whitespace and trimmed all sentence punctuation), so the
same first line could be stored differently depending on which path won.
Extract canonicalize_title() — whitespace collapse, unicode-arrow
normalization, char cap, trailing-punctuation trim — and call it from
both; length policy stays per-path. Also guard against a hint line that
canonicalizes to empty (e.g. '.').
Review findings from the codex xhigh pass:
- a 48-char cap cut inside ' -> ' left a dangling arrow fragment; strip
  ' ->' / ' -' suffixes after the cap and re-trim
- '#'-stripping ate issue-ref prefixes ('#27 -> x' lost its hash); only
  strip a Markdown ATX heading marker ('#' run followed by whitespace)
- unicode ellipsis '…' now rejects a hint line like its ASCII '...' twin
- unicode arrows are normalized BEFORE the hint length check, so a line
  that grows past the cap once ' → ' expands to ' -> ' is rejected, not
  truncated; adjacent arrows ('a → → b') all normalize via a stable loop
- an un-sluggable hint (all CJK/emoji/punctuation) produced a degenerate
  '<prefix>/<uuid>-' branch; the rename is now skipped (heuristic branch
  kept) while the user's title still lands, mirroring the model path's
  empty-slug bail-out
…ATX strip

Final review-battery findings:
- the dangling-arrow strip was single-pass, so chained tails ('a -> ->',
  cap cuts through 'x -> -> b') kept a fragment; trim punctuation and
  arrow fragments to a fixpoint (each pass strictly shortens, so it
  terminates)
- the arrow-hint length bound was measured before whitespace collapse,
  rejecting a line whose stored form is short ('bp<50sp>-><50sp>dogfood');
  acceptance now measures the collapsed+normalized form it would store
- heading stripping accepted 7+ leading hashes, which CommonMark treats
  as content, not an ATX heading; only strip 1-6
@miguelrisero miguelrisero merged commit fdef6a4 into main Jul 8, 2026
7 checks passed
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