feat: /pull-base lab command — user-driven base sync, pull-on-clear, behind-base badge #149

Closed
opened 2026-07-12 22:47:30 +02:00 by dominik.polakovics · 1 comment

Problem

Long-lived instances (grilling sessions especially) go stale when AFK runs land PRs: the operator grills, files an issue, an AFK agent merges it, and the next grill in the same session reasons against outdated code. Staleness has two layers — the worktree (git state behind origin/<base>) and the context (the model's cached reads of old code). Worktrees are forked once at spawn (gitx.AddWorktree) and never re-synced; gitx.Fetch only refreshes the bare reference repo.

This issue adds a user-driven sync: a /pull-base lab command, an automatic pull-before-clear, and a behind-base badge so the operator knows when to pull.

Design decisions (grilled 2026-07-12)

  1. Ownership — lab-native command. The server intercepts /pull-base in the composer reply path; it is never forwarded to the provider. Git ops run on lab's existing surface (gitx.Engine held by the services): fetch the bare reference repo, then merge origin/<base> inside run.WorktreePath. Reuse the crmerge patterns (internal/gitx/crmerge.go) for author identity, credential env (credentialEnv), and cancellation-immune cleanup. Cross-provider by construction (claude + codex).
  2. Merge policy — merge, abort on conflict. Fast-forward when possible, otherwise a real merge commit. On conflict: git merge --abort, worktree untouched, conflicting files reported to the operator. No extra pre-merge dirty check: git's own refusal (only when the merge would touch dirty files) is the right granularity; surface its message.
  3. Turn gating — reject while working. If the chat tailer's conversational state is working, fail fast with "agent is busy — retry when idle". No deferral/queue machinery. Allowed in idle / needs-input / question-pending.
  4. Digest — summary + self-serve pointer, injected on success only. Send down the ordinary Reply path (tmux paste, so keep it compact): merge range (<old>..<new> SHAs), commit subjects (with PR/issue refs as they appear), a capped name-status file list, and a closing instruction that prior reads of these files may be stale — re-read what you rely on, run git log/git diff <old>..<new> for detail. Errors (busy, conflict, fetch failure) surface to the operator as command feedback in the UI, never as an agent message.
  5. Pull-on-clear — act on send. The Reply handler recognizes the role=clear command text (already tagged via provider.CommandRoleClear, and backing the New-conversation action) and runs the pull before forwarding the clear, so the fresh context's first read sees fresh disk. Silent on success (nothing is stale post-clear, no digest needed). Loud on failure (toast/notice: base stayed old). If state is working, skip the pull with a notice — never block the clear itself. Clears typed directly in tmux are out of scope (escape hatch).
  6. Base derivation. v1 derives the base from repos.default_branch (there is no runs.base_branch yet). Write the seam as "the run's base" so #130/#131 (per-run base branch) slots in without rework.
  7. Behind-base badge. Server computes commits-behind (rev-list --count of origin/<base> not reachable from the run branch — mirror of the existing gitx.CommitsAhead) against the bare repo and exposes it on the runs/chat API. UI shows an "N behind" chip on the chat header and runs rail; hidden at 0. Freshness rides the existing fetch cadence (sweeps, reaper, and crmerge itself — lab knows the instant it advances a base); externally-pushed commits appear at the next fetch. The badge and the digest share the same comparison primitive.
  8. Vocabulary. New CONTEXT.md concept: Lab command — a composer slash command the server intercepts and executes itself, never forwarded to the provider; surfaced in the slash-command catalog alongside provider commands. /pull-base is the first. Update the catalog entry description in CONTEXT.md accordingly.

Implementation order (build server-first; if squeezed, the badge chip styling is the cuttable tail)

  1. gitx: comparison primitive (CommitsBehind / base-status: behind count + old/new SHAs + name-status list) against the bare repo; merge-into-worktree op with abort-on-conflict (model on mergeInTempWorktree, but target run.WorktreePath).
  2. Pull service: fetch → gate on conversational state → merge → build digest. Wire credential env + author identity like crmerge.
  3. Reply-path interception in internal/chat / internal/httpapi: recognize /pull-base, execute, inject digest on success, return errors as command feedback. Surface /pull-base in the slash-command catalog (GET /runs/{id}/commands) as a lab-owned entry.
  4. Clear-hook on the same path: detect role=clear text → pull first (skip-with-notice when working or on failure) → forward the clear.
  5. API: behind-count field on the runs/chat responses.
  6. Web: "N behind" chip on chat header + runs rail.
  7. Docs: CONTEXT.md "Lab command" vocabulary entry + catalog wording.

Acceptance criteria

  • /pull-base on an idle instance whose base advanced: worktree contains the new commits (ff or merge commit), and the chat shows an injected digest with the range, subjects, file list, and re-read instruction.
  • /pull-base while the agent is working: no git ops run; the operator sees "agent is busy".
  • /pull-base with a conflicting local commit: worktree left exactly as before (merge aborted), operator sees the conflicting files, no agent message.
  • Sending the provider's clear command (or New conversation) on a behind instance: pull happens before the clear; the fresh conversation reads updated code; no digest message. On pull failure the clear still goes through and the operator is notified.
  • Behind badge shows the count on chat header and runs rail, disappears at 0, and updates after crmerge advances the base.
  • CONTEXT.md defines "Lab command"; the catalog lists /pull-base for every instance including AFK runs.

Out of scope / accepted residuals

  • Conflict resolution stays manual (a later --resolve mode handing conflicts to the agent is a possible extension — separate issue if wanted).
  • Clears typed directly in tmux bypass the pull.
  • Staleness between fetches for pushes that didn't go through lab.
  • No arguments on the command in v1 (always the run's base).
## Problem Long-lived instances (grilling sessions especially) go stale when AFK runs land PRs: the operator grills, files an issue, an AFK agent merges it, and the next grill in the same session reasons against outdated code. Staleness has two layers — the **worktree** (git state behind `origin/<base>`) and the **context** (the model's cached reads of old code). Worktrees are forked once at spawn (`gitx.AddWorktree`) and never re-synced; `gitx.Fetch` only refreshes the bare reference repo. This issue adds a user-driven sync: a **`/pull-base` lab command**, an automatic **pull-before-clear**, and a **behind-base badge** so the operator knows when to pull. ## Design decisions (grilled 2026-07-12) 1. **Ownership — lab-native command.** The server intercepts `/pull-base` in the composer reply path; it is never forwarded to the provider. Git ops run on lab's existing surface (`gitx.Engine` held by the services): fetch the bare reference repo, then merge `origin/<base>` inside `run.WorktreePath`. Reuse the `crmerge` patterns (`internal/gitx/crmerge.go`) for author identity, credential env (`credentialEnv`), and cancellation-immune cleanup. Cross-provider by construction (claude + codex). 2. **Merge policy — merge, abort on conflict.** Fast-forward when possible, otherwise a real merge commit. On conflict: `git merge --abort`, worktree untouched, conflicting files reported to the operator. No extra pre-merge dirty check: git's own refusal (only when the merge would touch dirty files) is the right granularity; surface its message. 3. **Turn gating — reject while `working`.** If the chat tailer's conversational state is `working`, fail fast with "agent is busy — retry when idle". No deferral/queue machinery. Allowed in idle / needs-input / question-pending. 4. **Digest — summary + self-serve pointer, injected on success only.** Send down the ordinary Reply path (tmux paste, so keep it compact): merge range (`<old>..<new>` SHAs), commit subjects (with PR/issue refs as they appear), a capped name-status file list, and a closing instruction that prior reads of these files may be stale — re-read what you rely on, run `git log`/`git diff <old>..<new>` for detail. Errors (busy, conflict, fetch failure) surface to the operator as command feedback in the UI, never as an agent message. 5. **Pull-on-clear — act on send.** The Reply handler recognizes the role=`clear` command text (already tagged via `provider.CommandRoleClear`, and backing the New-conversation action) and runs the pull **before** forwarding the clear, so the fresh context's first read sees fresh disk. Silent on success (nothing is stale post-clear, no digest needed). Loud on failure (toast/notice: base stayed old). If state is `working`, skip the pull with a notice — never block the clear itself. Clears typed directly in tmux are out of scope (escape hatch). 6. **Base derivation.** v1 derives the base from `repos.default_branch` (there is no `runs.base_branch` yet). Write the seam as "the run's base" so #130/#131 (per-run base branch) slots in without rework. 7. **Behind-base badge.** Server computes commits-behind (`rev-list --count` of `origin/<base>` not reachable from the run branch — mirror of the existing `gitx.CommitsAhead`) against the bare repo and exposes it on the runs/chat API. UI shows an "N behind" chip on the chat header and runs rail; hidden at 0. Freshness rides the existing fetch cadence (sweeps, reaper, and crmerge itself — lab knows the instant it advances a base); externally-pushed commits appear at the next fetch. The badge and the digest share the same comparison primitive. 8. **Vocabulary.** New CONTEXT.md concept: **Lab command** — a composer slash command the server intercepts and executes itself, never forwarded to the provider; surfaced in the slash-command catalog alongside provider commands. `/pull-base` is the first. Update the catalog entry description in CONTEXT.md accordingly. ## Implementation order (build server-first; if squeezed, the badge chip styling is the cuttable tail) 1. `gitx`: comparison primitive (`CommitsBehind` / base-status: behind count + old/new SHAs + name-status list) against the bare repo; merge-into-worktree op with abort-on-conflict (model on `mergeInTempWorktree`, but target `run.WorktreePath`). 2. Pull service: fetch → gate on conversational state → merge → build digest. Wire credential env + author identity like `crmerge`. 3. Reply-path interception in `internal/chat` / `internal/httpapi`: recognize `/pull-base`, execute, inject digest on success, return errors as command feedback. Surface `/pull-base` in the slash-command catalog (`GET /runs/{id}/commands`) as a lab-owned entry. 4. Clear-hook on the same path: detect role=`clear` text → pull first (skip-with-notice when working or on failure) → forward the clear. 5. API: behind-count field on the runs/chat responses. 6. Web: "N behind" chip on chat header + runs rail. 7. Docs: CONTEXT.md "Lab command" vocabulary entry + catalog wording. ## Acceptance criteria - `/pull-base` on an idle instance whose base advanced: worktree contains the new commits (ff or merge commit), and the chat shows an injected digest with the range, subjects, file list, and re-read instruction. - `/pull-base` while the agent is working: no git ops run; the operator sees "agent is busy". - `/pull-base` with a conflicting local commit: worktree left exactly as before (merge aborted), operator sees the conflicting files, no agent message. - Sending the provider's clear command (or New conversation) on a behind instance: pull happens before the clear; the fresh conversation reads updated code; no digest message. On pull failure the clear still goes through and the operator is notified. - Behind badge shows the count on chat header and runs rail, disappears at 0, and updates after crmerge advances the base. - CONTEXT.md defines "Lab command"; the catalog lists `/pull-base` for every instance including AFK runs. ## Out of scope / accepted residuals - Conflict resolution stays manual (a later `--resolve` mode handing conflicts to the agent is a possible extension — separate issue if wanted). - Clears typed directly in tmux bypass the pull. - Staleness between fetches for pushes that didn't go through lab. - No arguments on the command in v1 (always the run's base).
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #150

Verdict: PASS (with three non-blocking concerns, below).

Verification signal relied on

Forgejo Actions ci / nativegreen (5m4s, run 165). That job is the repo's required check and covers the whole surface this diff touches: SPA lint + format:check + vitest + vite build, the ui-tagged Go build and full go test (with real git/tmux/prlimit on PATH), and golangci-lint. It was not re-run. ci-nix is path-gated to nix/dependency changes and this PR touches none, so its absence is correct.

Checked

  • Mergeableafk/149 already contains origin/main; merge-tree is clean. No conflict, no resolution commit.
  • Conventions — title is Conventional Commits; body carries a working Closes #149; head is afk/149 (AFK contract satisfied).
  • Spec — cross-checked against #149 point by point: all 8 decisions and all 6 acceptance criteria implemented, all three non-goals respected (no --resolve, tmux-typed clears untouched, args rejected with a 400).
  • Diff — the load-bearing invariants are real and tested: /pull-base is intercepted before any forward and is a 503 (never a forward) when unwired, asserted via an empty prov.Replies() on every error path; gitx's worktree-untouched failure contract aborts under a cancellation-immune context and is pinned byte-identically; pulls of the same run serialize on a per-run mutex.
  • Tests — every claim in the PR body is backed by tests that exist in the diff (9 gitx, 8 pull, 12 httpapi interception, plus TestAPI_RunCommitsBehind and TestMergePublishesRunChanged, plus 10 web).

Concerns (non-blocking — none is a regression; each degrades to today's behaviour)

  1. An arg-bearing clear silently skips the pull. isClearCommand requires an exact cmd == "/"+c.Name (internal/httpapi/chat.go:372), but claude-code's clear entry carries ArgHint: "[name]" (internal/provider/claudecode/commands.go:42). So /clear my-session forwards without a pull and without a notice. The bare /clear — the common case — works. Notable because the autocomplete gesture model (#122) makes an arg-hinted command insert rather than send, which actively invites the operator to type a name.
  2. The no-identity refusal fires even for a fast-forward. internal/pull/pull.go:182-188 returns ErrNoAuthorIdentity before any git runs, but a ff pull authors no commit and needs no identity. On a repo with no git_author_name/email, /pull-base fails even where it would trivially fast-forward.
  3. GET /instances now forks one git rev-list per active instance row (internal/httpapi/runs.go:112-121 via instances.go:104-105). The code comment's "O(1) queries" is true of the SQL but not of the git subprocesses, and this endpoint is refetched on every run.changed / debounced run.messages.changed. Bounded by the active-instance count, so it is small today — but the cost is unremarked in the comment that claims otherwise.

Concerns 1 and 2 are worth a follow-up issue; none blocks the merge.

> *This was generated by AI while landing a PR.* ## Landing audit — PR #150 **Verdict: PASS** (with three non-blocking concerns, below). ### Verification signal relied on Forgejo Actions `ci / native` — **green** (5m4s, run 165). That job is the repo's required check and covers the whole surface this diff touches: SPA lint + `format:check` + vitest + `vite build`, the `ui`-tagged Go build and full `go test` (with real git/tmux/prlimit on PATH), and golangci-lint. It was **not re-run**. `ci-nix` is path-gated to nix/dependency changes and this PR touches none, so its absence is correct. ### Checked - **Mergeable** — `afk/149` already contains `origin/main`; merge-tree is clean. No conflict, no resolution commit. - **Conventions** — title is Conventional Commits; body carries a working `Closes #149`; head is `afk/149` (AFK contract satisfied). - **Spec** — cross-checked against #149 point by point: all 8 decisions and all 6 acceptance criteria implemented, all three non-goals respected (no `--resolve`, tmux-typed clears untouched, args rejected with a 400). - **Diff** — the load-bearing invariants are real and tested: `/pull-base` is intercepted before any forward and is a 503 (never a forward) when unwired, asserted via an empty `prov.Replies()` on every error path; gitx's worktree-untouched failure contract aborts under a cancellation-immune context and is pinned byte-identically; pulls of the same run serialize on a per-run mutex. - **Tests** — every claim in the PR body is backed by tests that exist in the diff (9 gitx, 8 pull, 12 httpapi interception, plus `TestAPI_RunCommitsBehind` and `TestMergePublishesRunChanged`, plus 10 web). ### Concerns (non-blocking — none is a regression; each degrades to today's behaviour) 1. **An arg-bearing clear silently skips the pull.** `isClearCommand` requires an exact `cmd == "/"+c.Name` (`internal/httpapi/chat.go:372`), but claude-code's clear entry carries `ArgHint: "[name]"` (`internal/provider/claudecode/commands.go:42`). So `/clear my-session` forwards without a pull and *without a notice*. The bare `/clear` — the common case — works. Notable because the autocomplete gesture model (#122) makes an arg-hinted command insert rather than send, which actively invites the operator to type a name. 2. **The no-identity refusal fires even for a fast-forward.** `internal/pull/pull.go:182-188` returns `ErrNoAuthorIdentity` before any git runs, but a ff pull authors no commit and needs no identity. On a repo with no `git_author_name`/`email`, `/pull-base` fails even where it would trivially fast-forward. 3. **`GET /instances` now forks one `git rev-list` per active instance row** (`internal/httpapi/runs.go:112-121` via `instances.go:104-105`). The code comment's "O(1) queries" is true of the SQL but not of the git subprocesses, and this endpoint is refetched on every `run.changed` / debounced `run.messages.changed`. Bounded by the active-instance count, so it is small today — but the cost is unremarked in the comment that claims otherwise. Concerns 1 and 2 are worth a follow-up issue; none blocks the merge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#149
No description provided.