feat(kanban): merge-all branches + floating running-agents dock

Two board-level additions:

Merge all reviewable agent branches into the main branch sequentially:
- worktrees.mergeBranches() merges candidates in order, advancing HEAD each
  time; refuses if the main tree is dirty, and on the first conflict aborts
  that merge (main left clean) and stops — prior merges stay, untried
  branches reported as unattempted.
- runManager.mergeAll() picks candidates (settled runs that own their
  worktree, branch present, not yet in HEAD; deduped, oldest-first) — so
  refinement runs (which inherit a worktree) are correctly excluded.
- POST /runs/merge-all; ⬇ Merge all button in the board header opens a
  results modal (per-branch ✓/⇢/⚠, conflict banner, inline git output).

Floating dock to view/open running agents (front-end only):
- RunningAgentsBar pins bottom-right (below the card modal), lists every
  running run with live elapsed times, and opens that card's modal on click;
  auto-hides when nothing runs, collapsible otherwise.
EOF && echo "" && git log --oneline -4
This commit is contained in:
2026-06-17 21:04:51 -04:00
parent 408bdb6dd7
commit d538ccdd4e
8 changed files with 636 additions and 1 deletions

View File

@@ -15,7 +15,7 @@ import { newToken, REPO_ROOT } from './config.js';
import { buildPrompt } from './prompt.js';
import { Runner, type PersistedEvent } from './runner.js';
import { BevyProcess } from './bevy.js';
import { createWorktree, dirtySummary, headSha, isWorktreePresent, removeWorktree, type Worktree } from './worktrees.js';
import { createWorktree, dirtySummary, headSha, isWorktreePresent, isMergedIntoHead, mergeBranches, removeWorktree, type BatchMergeResult, type Worktree } from './worktrees.js';
export interface StartRunInput {
cardId: string;
@@ -267,6 +267,40 @@ class RunManager {
return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun);
}
/**
* Merge all reviewable agent branches into the main branch sequentially.
*
* Candidates are settled runs that own their worktree and have commits not
* yet in HEAD, oldest first (so merges land in roughly the order the runs
* happened). De-dups by branch and skips anything already merged. The actual
* sequential merge + conflict handling is in `worktrees.mergeBranches`.
*/
mergeAll(): BatchMergeResult {
const rows = db
.prepare(
`SELECT * FROM agent_runs
WHERE status IN ('completed','failed','stopped')
AND owns_worktree = 1
AND branch IS NOT NULL
ORDER BY created_at ASC`,
)
.all() as AgentRunRow[];
const seenBranch = new Set<string>();
const candidates: { branch: string; runId: string }[] = [];
for (const row of rows) {
const branch = row.branch!;
if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch)
seenBranch.add(branch);
// Skip branches already fully in HEAD — nothing to merge, and including
// them would let an unrelated failure mask that they're done.
if (isMergedIntoHead(branch)) continue;
candidates.push({ branch, runId: row.id });
}
return mergeBranches(candidates);
}
/** Replayable event history for a run, optionally after a sequence number. */
events(id: string, sinceSeq = 0): AgentRunEvent[] {
const rows = db

View File

@@ -177,6 +177,71 @@ export interface MergeResult {
output: string;
}
/** Outcome of a single branch in a batch merge. */
export interface BatchMergeItem extends MergeResult {
/** The run this branch belonged to (for UI correlation). */
runId: string;
}
/** Result of merging several branches sequentially into the main branch. */
export interface BatchMergeResult {
target: string;
/** One entry per candidate branch, in the order processed. */
items: BatchMergeItem[];
/** The branch that conflicted and stopped the batch, if any. Branches after
* it were not attempted. */
haltedOn: string | null;
/** True if the main worktree was dirty and nothing was attempted. */
aborted: boolean;
/** Human-readable reason when `aborted` or `haltedOn`. */
reason: string | null;
}
/**
* Merge multiple agent branches into the main worktree's checked-out branch,
* sequentially. Each successful merge advances HEAD, so later branches merge
* against the accumulated result.
*
* Safety: refuses outright if the main tree is dirty. On the first conflict,
* aborts that merge (leaving the main tree clean), records it as `haltedOn`,
* and stops — branches after it are NOT attempted, and prior successful merges
* stay in place. The caller should surface `haltedOn` + remaining branches so
* the operator can resolve or re-run after fixing the conflict.
*
* @param candidates branches to merge, in order, each tagged with its run id.
* Already-merged branches are reported as no-ops and skipped (never halt).
*/
export function mergeBranches(candidates: { branch: string; runId: string }[]): BatchMergeResult {
const target = currentBranch();
const dirty = mainDirtySummary();
if (dirty) {
return {
target,
items: [],
haltedOn: null,
aborted: true,
reason: `Cannot merge: ${dirty}`,
};
}
const items: BatchMergeItem[] = [];
let haltedOn: string | null = null;
let reason: string | null = null;
for (const { branch, runId } of candidates) {
const result = mergeBranch(branch);
items.push({ ...result, runId });
if (!result.ok) {
// A conflict aborts the batch: stop here, leaving earlier merges in place.
haltedOn = branch;
reason = `Conflict merging ${branch} — aborted, ${target} left clean.`;
break;
}
}
return { target, items, haltedOn, aborted: false, reason };
}
/**
* Merge an agent branch into the main worktree's checked-out branch.
*

View File

@@ -144,6 +144,9 @@ orchestrator.post('/runs/:id/merge', (c) => {
return c.json(mergeBranch(run.branch));
});
/** Merge all reviewable agent branches into the main branch sequentially. */
orchestrator.post('/runs/merge-all', (c) => c.json(runManager.mergeAll()));
/** Whether a Bevy playtest is running for a run. */
orchestrator.get('/runs/:id/bevy', (c) => {
const run = runManager.get(c.req.param('id'));