Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Rules:
`================ [WHY] =================`
`================ [NEXT] ================`
- Show `2-5` candidates in `CANDIDATES`.
- Every candidate must be a **genuinely viable option** — something a reasonable engineer could actually pick. No strawmen, no filler options that obviously lose, no "do nothing" / "give up" padding just to reach a count.
- Candidates must be **maximally diverse approaches** to the same problem: different layers (backend vs UI), different mechanisms (fix vs redesign vs config), different scopes (targeted patch vs broader refactor) — not the same idea with cosmetic variations, and not "subset of the chosen plan" vs "the chosen plan".
- If only one reasonable approach exists, skip the `CANDIDATES` section entirely and say so in `DECISION` — a fake alternatives list is worse than none.
- Mark the selected candidate with `(chosen)`.
- Each candidate may use up to 5 short lines.
- `CANDIDATES` is for concise option framing, not full justification.
Expand Down Expand Up @@ -128,13 +131,15 @@ gh auth switch --user h0x91b 2>/dev/null || true

This is a no-op for collaborators who don't have the `h0x91b` account — `gh` will fall back to whatever account they have configured.

**PRs are squash-merged.** To enable auto-merge, always pass the strategy flag: `gh pr merge --auto --squash <branch>`. A bare `gh pr merge --auto` fails non-interactively ("--merge, --rebase, or --squash required").

## Changelog policy

**For every code change, create a changelog entry file.** This avoids merge conflicts when multiple agents work in parallel.

**Path:** `change-logs/YYYY/MM/DD/<type>-<short-slug>.md`

**The `YYYY/MM/DD` is the expected PR merge date — not the date you started.** If a task spans more than one day, do not leave the entry under the day you created it. Before opening/merging the PR, move (rename) the file/folder so the date matches the actual merge day. The changelog UI groups entries by ship date, so a stale start-date silently files the feature under the wrong day.
**The `YYYY/MM/DD` is the expected PR merge date — not the date you started.** If a task spans more than one day, do not leave the entry under the day you created it. Before opening/merging the PR, move (rename) the file/folder so the date matches the actual merge day (with auto-merge, that is normally the day you open the PR). The changelog UI groups entries by ship date, so a stale start-date silently files the feature under the wrong day.

**Type prefixes:** `feature-`, `fix-`, `refactor-`, `docs-`, `chore-`

Expand Down Expand Up @@ -244,7 +249,7 @@ Two-process model:

The renderer and main process communicate via **Electrobun's built-in RPC** (IPC bridge). The schema is defined in `src/shared/types.ts` as `AppRPCSchema` with two channels: `bun` (main process) and `webview` (renderer).

- **Request/response:** Components call `api.request.METHOD(params)` (returns a Promise, 2-minute timeout). Handlers live in `src/bun/rpc-handlers/*.ts`, split by domain (`app-handlers`, `settings-config`, `task-lifecycle`, `git-operations`, `tmux-pty`, `notes-labels`, `remote-access`). The root `src/bun/rpc-handlers.ts` is a barrel re-exporter that merges them into a single `handlers` object.
- **Request/response:** Components call `api.request.METHOD(params)` (returns a Promise, 2-minute timeout). Handlers live in `src/bun/rpc-handlers/*.ts`, split by domain (`app-handlers`, `settings-config`, `task-lifecycle`, `git-operations`, `tmux-pty`, `notes-labels`, `remote-access`, `port-tunnels`, `scripts`; `shared.ts`/`shared-pure.ts` are cross-domain helpers, not domains). The root `src/bun/rpc-handlers.ts` is a barrel re-exporter that merges them into a single `handlers` object.
- **Push messages:** The main process sends unsolicited updates via `pushMessage?.("eventName", payload)`. The renderer dispatches these as `CustomEvent`s (e.g., `rpc:taskUpdated`), which components listen to with `window.addEventListener()`.

### State management
Expand All @@ -255,9 +260,9 @@ UI state uses React's **`useReducer`** pattern (no external state library). The
- Components call `api.request.*` to fetch/mutate backend data, then `dispatch()` reducer actions to update local state.
- Push messages from the main process trigger event listeners that dispatch actions to keep the UI in sync.

### HMR mechanism
### Renderer asset loading (dev-channel Vite fallback)

The main process checks if the Vite dev server is running on `localhost:5173`. If the app is on the `dev` channel and the server responds, it loads from Vite (HMR enabled). Otherwise it falls back to bundled assets via the `views://` protocol.
The main process checks if the Vite dev server is running on `localhost:5173`. If the app is on the `dev` channel and the server responds, it loads from Vite (HMR enabled). Otherwise it falls back to bundled assets via the `views://` protocol. This mechanism exists in the code for the `dev` channel, but agents must never run a Vite watch/HMR loop themselves — see the HMR ban in [Commands](#commands).

### Build pipeline

Expand Down Expand Up @@ -288,10 +293,10 @@ Each project has three lifecycle scripts, configurable in Project Settings (`src
| Field | When it runs |
|---|---|
| `setupScript` | After a new worktree is created for a task |
| `devScript` | When starting the dev server for the project (not yet wired up — reserved for future use) |
| `devScript` | When starting the task dev server (`dev3 dev-server start` or the UI button; runs in a tmux window — see `src/bun/rpc-handlers/tmux-pty.ts`) |
| `cleanupScript` | Before a task worktree is removed after `completed` or `cancelled` (and `archived` once that status is added) |

All three are free-form shell scripts. They are saved via the `updateProjectSettings` RPC handler in `src/bun/rpc-handlers.ts`.
All three are free-form shell scripts. They are saved via the `updateProjectSettings` RPC handler in `src/bun/rpc-handlers/settings-config.ts`.

## Styling & design tokens

Expand Down Expand Up @@ -383,7 +388,7 @@ Call with `t.plural("dashboard.projectCount", count)`.

### Adding a new locale

1. Create `src/mainview/i18n/translations/{locale}.ts` with type `TranslationRecord & Record<string, string>`
1. Create a `src/mainview/i18n/translations/{locale}/` directory with domain files mirroring `en/`, plus a barrel `src/mainview/i18n/translations/{locale}.ts` that merges them (copy the structure of `ru.ts`); the barrel must satisfy `TranslationRecord`
2. Add the locale to `ALL_LOCALES` and `LOCALE_LABELS` in `src/mainview/i18n/types.ts`
3. Import and register in `src/mainview/i18n/context.tsx` (`translationSets`)
4. Add plural rules in `src/mainview/i18n/interpolate.ts` (`getPluralForm`)
Expand Down
1 change: 1 addition & 0 deletions change-logs/2026/06/10/fix-merge-prompt-reliability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed several reliability issues in the "PR merged" completion popup: a prompt lost to an app restart was suppressed forever (now it re-appears after an hour if unanswered, while explicit dismissals stay permanent), tasks whose remote branch was pruned after merge (delete_branch_on_merge) were never detected (now verified via the GitHub merged-PR check), the popup could appear while uncommitted changes remained in the worktree, and it could fire when comparing against a non-base ref in the diff dropdown. Dismissed prompts also no longer burn git/gh calls on every poller tick.
22 changes: 22 additions & 0 deletions decisions/066-merge-prompt-retry-and-gating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 066 — Merge-completion prompt: retry window, pruned-branch fallback, dirty/compare gating

## Context

The "PR merged — complete the task?" popup periodically misbehaved: it silently never appeared for some merged tasks, and could appear when it shouldn't. Live data showed tasks stuck with `mergeCompletionPrompt.promptedAt` set, `dismissedAt: null`, still in review — the popup was reserved but the user never saw it (app restart / undelivered push), and the precise-fingerprint suppression in `shouldSuppressMergePrompt` muted it forever.

## Investigation

Five independent problems were confirmed in `src/bun/rpc-handlers/git-operations.ts` and `src/mainview/components/task-info-panel/useTaskBranchStatus.ts`:
(A) prompt state is persisted *before* display, and precise fingerprints were suppressed permanently regardless of `dismissedAt`; (B) `getUnpushedCount === -1` (remote branch pruned by `delete_branch_on_merge`) skipped detection entirely; (C) no uncommitted-changes check before a popup that claims "no changes left"; (D) the UI prompt fired for `mergedByContent` computed against any user-selected compare ref; (E) dismissed tasks re-ran merge-tree/patch-id/gh checks every 60s forever.

## Decision

Permanent suppression now requires an explicit `dismissedAt`; an unanswered reservation only mutes re-prompts for 1h (`MERGE_PROMPT_RETRY_SUPPRESS_MS`), and the in-memory key set became a `Map<promptKey, reservedAt>` with the same TTL. Strategy 3 of `isContentMergedInto` was extracted into `git.isBranchMergedViaGitHubPR` (trusted only when the merged PR's `headRefOid` equals local HEAD) and is used by the poller when the remote branch is gone. The poller checks suppression first, then skips dirty worktrees *without* reserving; the UI prompt additionally requires a clean worktree and the default base compare ref.

## Risks

A user who ignores the popup (closes it without answering) will see it again an hour later — intentional, but a behavior change. The pruned-branch path adds a gh API call per never-pushed review task per minute; acceptable against the 5000/h limit and offset by the savings from (E).

## Alternatives considered

Persisting "popup actually displayed" acknowledgements from the renderer would remove the reserve-before-display race entirely, but requires a new RPC round-trip and still fails on renderer crash; the retry window is simpler and self-healing. Treating `unpushed === -1` as merged when content strategies pass was rejected: a never-pushed branch with zero commits trivially "matches" the base and would false-positive.
71 changes: 70 additions & 1 deletion src/bun/__tests__/git-merge-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock("../spawn", async () => {
return createSpawnMock(() => ghPrListResponse);
});

import { isContentMergedInto } from "../git";
import { isBranchMergedViaGitHubPR, isContentMergedInto } from "../git";
import { createTestRepo, cleanup, makeTaskCommits, g, type TestRepo } from "./git-test-helpers";

// ─── Tests ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -255,3 +255,72 @@ describe("isContentMergedInto", () => {
expect(result).toBe(false);
});
});

describe("isBranchMergedViaGitHubPR", () => {
let repo: TestRepo;

beforeEach(() => {
repo = createTestRepo();
ghPrListResponse = "[]";
});

afterEach(() => {
cleanup(repo);
});

it("returns true when a merged PR's head commit matches the current local HEAD", async () => {
// Simulates delete_branch_on_merge: the PR merged and origin/<branch>
// was pruned, so content strategies are unavailable — gh is the source
// of truth.
g("git checkout -b task-branch", repo.local);
makeTaskCommits(repo.local);

const headSha = g("git rev-parse HEAD", repo.local).trim();
ghPrListResponse = JSON.stringify([{ number: 42, headRefOid: headSha }]);

const result = await isBranchMergedViaGitHubPR(repo.local);
expect(result).toBe(true);
});

it("returns false when no merged PR exists for the branch", async () => {
g("git checkout -b task-branch", repo.local);
makeTaskCommits(repo.local);

const result = await isBranchMergedViaGitHubPR(repo.local);
expect(result).toBe(false);
});

it("returns false when the merged PR's head commit does not match current HEAD (reused branch name)", async () => {
g("git checkout -b task-branch", repo.local);
makeTaskCommits(repo.local);

ghPrListResponse = JSON.stringify([
{ number: 7, headRefOid: "0000000000000000000000000000000000000000" },
]);

const result = await isBranchMergedViaGitHubPR(repo.local);
expect(result).toBe(false);
});

it("returns false on detached HEAD", async () => {
g("git checkout -b task-branch", repo.local);
makeTaskCommits(repo.local);
const headSha = g("git rev-parse HEAD", repo.local).trim();
g(`git checkout --detach ${headSha}`, repo.local);

ghPrListResponse = JSON.stringify([{ number: 42, headRefOid: headSha }]);

const result = await isBranchMergedViaGitHubPR(repo.local);
expect(result).toBe(false);
});

it("returns false when gh output is not valid JSON", async () => {
g("git checkout -b task-branch", repo.local);
makeTaskCommits(repo.local);

ghPrListResponse = "gh: command failed";

const result = await isBranchMergedViaGitHubPR(repo.local);
expect(result).toBe(false);
});
});
Loading