Compare commits

...

4 Commits

Author SHA1 Message Date
c65daba5dd docs(changelog): v2.6.2-delete-guard-and-sse
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:24:25 +00:00
c9e302da37 fix(coder): no-upstream branch alone no longer flags a session at-risk
Session worktree branches (session-<id>) never get an upstream, so the original atRisk rule (unpushed !== 0) flagged every worktree-backed session as at-risk on delete — even pristine ones — forcing a Stash/Force confirm on each. Gate the unpushed arm behind hasUpstream (unpushed !== -1) so the no-upstream sentinel can't trigger it: atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0). No protection is lost — any genuinely unsafe local commit also shows as unmerged > 0 — and the unpushed > 0 arm stays correct for P1.5's pushable worktree branches. unpushed is still reported (-1 = local-only) as informational. Follow-up to 3a26563.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:19:53 +00:00
f69ea5f494 feat(coder): per-session SSE subscriptions (P1.5-a concurrency prereq)
Replace the single global SSE loop (scoped to the most-recently-used worktree directory) with one subscription per live opencode session, each scoped to that session's worktree dir. Two sessions in different worktrees now stream concurrently instead of the second silently dropping the first's events. Each session owns an AbortController (SessionState.sseAbort) wired into subscribe(..., {signal}); the loop reconnects, reconciles (per-session), and is torn down on closeSession/dispose by aborting the signal — which also fixes a latent Phase-1 bug where switching directories left the old runEventLoop parked forever in its for-await (zombie loops). A sessionID demux guard (eventSessionId) drops events that aren't this loop's own, so two sessions sharing a worktree (possible after P1.5-b) don't double-process each other's deltas. Removed sseRunning/sseDirectory/startEventLoop/runEventLoop/reconcileInFlight and the 'SSE directory changed' collision warning. dispatchEvent/handleUpdatedPart (translation, dedup, dcp-strip) and the watchdog are unchanged — only the subscription topology changed. SDK confirmed: @opencode-ai/sdk Event.subscribe opens an independent SSE connection per call, so N concurrent dir-scoped streams are supported. No schema/dispatcher/frontend changes; runExternalAgent untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:15:55 +00:00
3a26563be2 feat(coder): guard session delete against worktree work loss
Deleting a BooChat session CASCADE-wipes its session_worktrees row, which would silently orphan uncommitted/unpushed/unmerged work in the worktree. Add a pre-DELETE gate: the server reads session_worktrees from the shared DB first (no row = chat-only session = delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint that runs git on the host (only the host systemd service can see /tmp/booworktrees). checkWorktreeWorkAtRisk reports dirty/unpushed/unmerged via the audited hostExec+shellEscape path; default branch is detected from refs/remotes/origin/HEAD (not the worktree's own branch), never hardcoded. Any at-risk worktree returns 409 with per-worktree RiskReport[]; force=true bypasses the check entirely. Fail-closed: coder unreachable/errored also blocks (force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force) from couldn't-verify (Cancel/Force only); stash uses -u and re-blocks on remaining commits with an explanatory message. Commit never auto-commits — it routes the user to the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:01:25 +00:00
10 changed files with 543 additions and 65 deletions

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.6.2-delete-guard-and-sse — 2026-05-30
Two coder-side batches under one tag. **Session-delete work-loss guard:** deleting a BooChat session CASCADE-wipes its `session_worktrees` row, which would silently orphan uncommitted/unpushed/unmerged work — so the server's `DELETE /api/sessions/:id` now gates before the delete. It reads `session_worktrees` from the shared DB first (no row → chat-only session → delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint (`/worktree-risk`) that runs git on the host, since the container can't see `/tmp/booworktrees` — only the host systemd service can. `checkWorktreeWorkAtRisk` reports dirty/unpushed/unmerged via the audited `hostExec`+`shellEscape` path, default branch detected from `refs/remotes/origin/HEAD` (never the worktree's own branch, never hardcoded); any at-risk worktree returns 409 with per-worktree `RiskReport[]`, `force=true` bypasses, and the check is fail-closed (BooCoder unreachable also blocks — force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force; stash uses `-u` and re-blocks on remaining commits) from couldn't-verify (Cancel/Force), and Commit never auto-commits. A follow-up fix gates the `unpushed` arm behind an actual upstream (`atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0)`) so the no-upstream `session-<id>` branches stop flagging every pristine worktree-backed session — no protection lost, since real local work always also surfaces as `unmerged > 0`. **Per-session SSE (P1.5-a):** replaces the single global SSE loop scoped to the most-recent worktree directory — the known limit flagged in `v2.6.1-phase1-opencode` — with one `event.subscribe({directory})` per live opencode session, so sessions in different worktrees stream concurrently instead of the second silently dropping the first's events. Each session owns an `AbortController` wired into `subscribe(…, {signal})`, which also fixes a latent Phase-1 bug where switching directories left the old loop parked forever in its `for await` (zombie loops); a `sessionID` demux guard drops cross-session events so two sessions sharing a worktree (possible after P1.5-b) don't double-process deltas. The opencode SDK was confirmed to open an independent SSE connection per `subscribe()` call, so N concurrent dir-scoped streams are supported.
## v2.6.1-phase1-opencode — 2026-05-30
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).

View File

@@ -30,6 +30,7 @@ import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
@@ -195,6 +196,7 @@ async function main() {
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is

View File

@@ -0,0 +1,45 @@
/**
* Session-delete work-loss guard (coder side).
*
* Session delete itself lives in apps/server (Docker), which CANNOT see the
* host worktree dirs (/tmp/booworktrees) or run git on them. Only BooCoder
* (host systemd) can. So the server's DELETE route calls these endpoints
* pre-delete to learn whether a session's worktree holds work at risk, and to
* stash it. The server owns the gate; coder owns the git truth.
*/
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
// GET risk for a session's worktree(s). One row per session today (PK on
// session_id); the loop already handles the Phase-1.5 multi-worktree case.
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/worktree-risk',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
`;
const reports = [];
for (const row of rows) {
reports.push(await checkWorktreeWorkAtRisk(row.worktree_path));
}
return { reports };
},
);
// Stash a session's worktree(s) — clears the dirty risk; recoverable.
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/worktree-stash',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
`;
const results = [];
for (const row of rows) {
results.push({ worktreePath: row.worktree_path, ...(await stashWorktree(row.worktree_path)) });
}
return { results };
},
);
}

View File

@@ -3,7 +3,9 @@
*
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed
* on switch-back); a single SSE read loop demuxes all sessions' events.
* on switch-back); one SSE read loop PER session, each scoped to that session's
* worktree directory so sessions in different directories stream concurrently
* (P1.5-a — replaced the Phase-1 single-stream-last-directory model).
*
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
@@ -73,6 +75,9 @@ interface SessionState {
activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | null;
/** Per-session SSE subscription handle. Non-null while the loop is running;
* aborting it tears down the underlying fetch and exits the loop. */
sseAbort: AbortController | null;
}
export interface OpenCodeServerBackendDeps {
@@ -94,7 +99,6 @@ export class OpenCodeServerBackend implements AgentBackend {
private port: number | null = null;
private up = false;
private serverStarting: Promise<void> | null = null;
private sseRunning = false;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>();
@@ -150,37 +154,58 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
/** Per-directory SSE subscription. opencode scopes events by directory (defaults
* to process.cwd if omitted) — so we must subscribe with the same directory used
* to create the session. Called from ensureSession; reconnects while up. */
private startEventLoop(directory: string): void {
if (this.sseRunning) return;
this.sseRunning = true;
this.sseDirectory = directory;
void this.runEventLoop(directory);
/** Per-session SSE subscription, scoped to the session's worktree directory.
* opencode scopes events by the `directory` query param (defaults to the
* server's cwd if omitted), so two sessions in different worktrees each get
* their own dir-scoped stream and never drop each other's events. Idempotent:
* a no-op if this session's loop is already running. Started from ensureSession
* (and defensively from prompt) once worktreePath is known. */
private startSessionEventLoop(state: SessionState): void {
if (state.sseAbort) return; // already running
const abort = new AbortController();
state.sseAbort = abort;
void this.runSessionEventLoop(state, abort).finally(() => {
// Only clear if this controller is still the live one (a later restart may
// have already installed a new one).
if (state.sseAbort === abort) state.sseAbort = null;
});
}
private sseDirectory: string | null = null;
private async runEventLoop(directory: string): Promise<void> {
while (this.up && this.client) {
private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise<void> {
const signal = abort.signal;
while (this.up && this.client && !signal.aborted) {
try {
const sub = await this.client.event.subscribe({ directory });
// Re-read worktreePath each (re)subscribe so a directory refresh is picked
// up on reconnect. Passing `signal` lets close/dispose tear down a stream
// that's parked in `for await` between events.
const sub = await this.client.event.subscribe(
{ directory: state.worktreePath },
{ signal },
);
for await (const ev of sub.stream) {
if (signal.aborted) break;
// Dir-scoped streams should only carry this session's events, but two
// sessions sharing a worktree (possible post-P1.5-b) each receive BOTH
// sessions' events — so drop anything that isn't ours, else the other
// session's deltas get processed twice (once per loop).
const sid = eventSessionId(ev);
if (sid != null && sid !== state.agentSessionId) continue;
this.dispatchEvent(ev);
}
if (this.up) {
await this.reconcileInFlight();
if (this.up && !signal.aborted) {
await this.reconcile(state); // recover an idle/error lost during the gap
await sleep(SSE_RECONNECT_DELAY_MS);
}
} catch (err) {
if (!this.up) break;
this.log.warn({ err: errMsg(err) }, 'opencode-server: event loop error; reconnecting');
await this.reconcileInFlight();
if (!this.up || signal.aborted) break;
this.log.warn(
{ err: errMsg(err), agentSessionId: state.agentSessionId },
'opencode-server: session event loop error; reconnecting',
);
await this.reconcile(state);
await sleep(SSE_RECONNECT_DELAY_MS);
}
}
this.sseRunning = false;
}
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
@@ -354,13 +379,6 @@ export class OpenCodeServerBackend implements AgentBackend {
}
}
/** Reconcile every in-flight turn against the server (called after an SSE drop). */
private async reconcileInFlight(): Promise<void> {
const states = [...this.byOpencodeId.values()].filter((s) => s.activeTurn);
if (states.length === 0) return;
await Promise.allSettled(states.map((s) => this.reconcile(s)));
}
/**
* Ask the server whether this session's turn already finished — recovers a
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
@@ -451,35 +469,14 @@ export class OpenCodeServerBackend implements AgentBackend {
// Both branches above guarantee agentSessionId is non-null.
const ocSessionId = agentSessionId!;
// Start (or re-start) the SSE event loop scoped to this session's directory.
// opencode scopes events by the `directory` query param; without it events
// default to the server's CWD which doesn't match our worktree paths.
//
// KNOWN Phase 1 LIMITATION: one SSE stream at a time, scoped to a single
// directory. Under 1.9 concurrency, if two opencode sessions use different
// worktree directories simultaneously, re-subscribing for the second drops
// the first session's events (the watchdog backstop prevents a full hang,
// but streamed content is lost). Phase 2 should move to per-session SSE
// subscriptions or a directory-agnostic event path.
if (!this.sseRunning || this.sseDirectory !== opts.worktreePath) {
if (this.sseRunning && this.sseDirectory && this.sseDirectory !== opts.worktreePath) {
this.log.warn(
{ prev: this.sseDirectory, next: opts.worktreePath },
'opencode-server: SSE directory changed — concurrent sessions will lose events from the previous directory',
);
}
this.sseRunning = false;
this.startEventLoop(opts.worktreePath);
}
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
// entry (and any in-flight turn) — just refresh the routing fields.
const existing = this.byOpencodeId.get(ocSessionId);
if (existing) {
existing.boocodeSessionId = sessionId;
existing.worktreePath = opts.worktreePath;
let state = this.byOpencodeId.get(ocSessionId);
if (state) {
state.boocodeSessionId = sessionId;
state.worktreePath = opts.worktreePath;
} else {
this.byOpencodeId.set(ocSessionId, {
state = {
boocodeSessionId: sessionId,
agentSessionId: ocSessionId,
worktreePath: opts.worktreePath,
@@ -487,9 +484,16 @@ export class OpenCodeServerBackend implements AgentBackend {
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
});
sseAbort: null,
};
this.byOpencodeId.set(ocSessionId, state);
}
// Start this session's own SSE loop, scoped to its worktree directory. Both
// fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a
// second turn) won't spawn a duplicate loop.
this.startSessionEventLoop(state);
return {
sessionId,
agent: opts.agent,
@@ -516,12 +520,17 @@ export class OpenCodeServerBackend implements AgentBackend {
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
};
this.byOpencodeId.set(oc, state);
}
const session = state;
// Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath;
// Defensive: ensureSession normally starts the loop, but if prompt is reached
// with a freshly-created state (no loop yet), start it so the turn streams.
// Idempotent when ensureSession already started one.
this.startSessionEventLoop(session);
const client = this.client;
return await new Promise<TurnResult>((resolve) => {
@@ -577,7 +586,11 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
if (handle.agentSessionId) this.byOpencodeId.delete(handle.agentSessionId);
if (handle.agentSessionId) {
// Stop this session's SSE loop before dropping its demux entry.
this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort();
this.byOpencodeId.delete(handle.agentSessionId);
}
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
@@ -586,6 +599,8 @@ export class OpenCodeServerBackend implements AgentBackend {
async dispose(): Promise<void> {
this.up = false;
// Abort every per-session SSE loop so none survive the teardown.
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
const child = this.child;
this.child = null;
this.client = null;
@@ -602,6 +617,20 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Extract the opencode sessionID an event belongs to, across event shapes.
* Most carry `properties.sessionID`; `message.part.updated` nests it under
* `properties.part.sessionID`. Returns null when the event has no session
* (the per-session loop then leaves it to dispatchEvent, which drops it). */
function eventSessionId(ev: Event): string | null {
const props = (ev as { properties?: unknown }).properties;
if (!props || typeof props !== 'object') return null;
if (ev.type === 'message.part.updated') {
const part = (props as { part?: { sessionID?: string } }).part;
return part?.sessionID ?? null;
}
return (props as { sessionID?: string }).sessionID ?? null;
}
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined;

View File

@@ -182,6 +182,165 @@ export async function ensureSessionWorktree(
};
}
// ─── Session-delete work-loss guard ─────────────────────────────────────────
/**
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
* `atRisk` is the gate the server reads before allowing a session delete.
* A git error never silently passes — it forces `atRisk` true and surfaces
* the message in `error` (fail-closed).
*/
export interface RiskReport {
worktreePath: string;
branch: string;
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
unmerged: number; // commits on this branch not in the project default branch
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
error?: string; // populated on a git failure; presence forces atRisk
}
/**
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
*
* `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared
* across every linked worktree, so reading it from the session worktree returns
* the REMOTE's default branch — never this worktree's own `session-<id>` branch
* (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing
* common defaults by verified existence when origin/HEAD isn't set (e.g. a repo
* that never ran `git remote set-head`). Returns null if none resolve, in which
* case the unmerged check is skipped (dirty + unpushed still protect the work).
*/
async function detectDefaultBranchRef(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<string | null> {
const head = await hostExec(
`git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (head.exitCode === 0) {
const ref = head.stdout.trim(); // e.g. "origin/main"
if (ref) {
const verify = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (verify.exitCode === 0 && verify.stdout.trim()) return ref;
}
}
// origin/HEAD unset or unresolvable — probe common defaults. Prefer the
// remote-tracking ref (always resolvable in a fresh worktree) over the local
// head, which may not exist if the default branch lives only in the main tree.
for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
const verify = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (verify.exitCode === 0 && verify.stdout.trim()) return cand;
}
return null;
}
/**
* Inspect a worktree for work that would be lost if its session were deleted.
* Three checks, all via the audited hostExec + shellEscape path (every
* interpolated value — paths, refs — is single-quote-escaped; no bare
* interpolation). Any unexpected git failure is treated as at-risk, never a
* silent pass.
*/
export async function checkWorktreeWorkAtRisk(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<RiskReport> {
// Branch name — also doubles as the "is this still a git worktree?" probe.
const br = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (br.exitCode !== 0) {
return {
worktreePath,
branch: '',
dirty: false,
unpushed: 0,
unmerged: 0,
atRisk: true,
error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`,
};
}
const branch = br.stdout.trim();
// (a) Uncommitted (dirty working tree, including untracked files).
const st = await hostExec(
`git -C ${shellEscape(worktreePath)} status --porcelain`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
if (st.exitCode !== 0) {
return {
worktreePath,
branch,
dirty: false,
unpushed: 0,
unmerged: 0,
atRisk: true,
error: `git status failed: ${st.stderr.trim()}`,
};
}
const dirty = st.stdout.trim().length > 0;
// (b) Unpushed commits. No upstream configured => work exists only locally;
// treat as unpushed-by-definition (-1) rather than an error.
const up = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1;
// (c) Unmerged commits — on this branch but not in the project default branch.
const defaultRef = await detectDefaultBranchRef(worktreePath, opts);
let unmerged = 0;
if (defaultRef) {
const rl = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0;
}
// unpushed only contributes when an upstream actually exists. Session branches
// (session-<id>) never have one (unpushed === -1), and any real local-only work
// there already surfaces as unmerged > 0 — so the no-upstream case adds no
// protection, only friction (it flagged every pristine worktree-backed session).
// The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches.
const hasUpstream = unpushed !== -1;
const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0);
return { worktreePath, branch, dirty, unpushed, unmerged, atRisk };
}
/**
* Stash a worktree's uncommitted changes (including untracked, via -u) so the
* working tree is clean. Stash entries live in the repo's common git dir, so
* they survive worktree-dir removal — this is the recoverable, safe-by-default
* escape. Note it only clears the *dirty* risk; unpushed/unmerged commits
* remain on the branch, so a re-attempted delete may still block on those.
*/
export async function stashWorktree(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<{ stashed: boolean; error?: string }> {
const r = await hostExec(
`git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (r.exitCode !== 0) {
return { stashed: false, error: r.stderr.trim() || r.stdout.trim() };
}
// "No local changes to save" => exit 0, nothing stashed — not an error.
const stashed = !/no local changes to save/i.test(r.stdout);
return { stashed };
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import type { Session, WorktreeRiskReport } from '../types/api.js';
import { getSetting } from './settings.js';
const CreateBody = z.object({
@@ -426,10 +426,53 @@ export function registerSessionRoutes(
}
);
app.delete<{ Params: { id: string } }>(
app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
'/api/sessions/:id',
async (req, reply) => {
const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. CASCADE on session_worktrees means the
// DELETE below auto-wipes the worktree row, so the safety check MUST run
// BEFORE it (paths read while the row still exists, pre-CASCADE).
//
// Optimization: read session_worktrees from our own (shared) DB first.
// No row => chat-only session => nothing on disk => delete immediately,
// zero round-trip. Only worktree-backed sessions pay the host git check.
if (!force) {
const worktrees = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id}
`;
if (worktrees.length > 0) {
// Worktree dirs live on the host; only BooCoder can run git on them.
const origin = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
let reports: WorktreeRiskReport[];
try {
const res = await fetch(`${origin}/api/sessions/${id}/worktree-risk`);
if (!res.ok) {
// Fail-closed: can't verify => don't risk silent loss. Force escapes.
reply.code(409);
return {
error: 'could not verify worktree safety (BooCoder check failed). Use force to delete anyway.',
reports: [] as WorktreeRiskReport[],
};
}
reports = ((await res.json()) as { reports?: WorktreeRiskReport[] }).reports ?? [];
} catch {
// Fail-closed: BooCoder unreachable. Force bypasses this path entirely.
reply.code(409);
return {
error: 'BooCoder unreachable; cannot verify worktree safety. Use force to delete anyway.',
reports: [] as WorktreeRiskReport[],
};
}
if (reports.some((r) => r.atRisk)) {
reply.code(409);
return { error: 'This session has work at risk in its worktree.', reports };
}
}
}
const deleted = await sql<{ project_id: string }[]>`
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
`;

View File

@@ -25,6 +25,20 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived';
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
// a delete is blocked because the session's worktree holds work at risk. The
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
export interface WorktreeRiskReport {
worktreePath: string;
branch: string;
dirty: boolean;
unpushed: number; // commits ahead of upstream, or -1 if no upstream
unmerged: number; // commits not in the project default branch
atRisk: boolean;
error?: string;
}
export interface Session {
id: string;
project_id: string;

View File

@@ -151,8 +151,17 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
// force=true bypasses the server-side worktree work-loss guard. A blocked
// delete throws ApiError(409) whose body carries { error, reports }.
remove: (id: string, force = false) =>
request<void>(`/api/sessions/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
// Stash the session's worktree (uncommitted changes) on the host, via the
// BooCoder proxy. Recoverable escape from the work-at-risk dialog.
worktreeStash: (id: string) =>
request<{ results: { worktreePath: string; stashed: boolean; error?: string }[] }>(
`/api/coder/sessions/${id}/worktree-stash`,
{ method: 'POST' },
),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>

View File

@@ -34,6 +34,19 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived';
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
// `reports` field of the 409 body when a delete is blocked.
export interface WorktreeRiskReport {
worktreePath: string;
branch: string;
dirty: boolean;
unpushed: number; // commits ahead of upstream, or -1 if no upstream
unmerged: number; // commits not in the project default branch
atRisk: boolean;
error?: string;
}
export interface Session {
id: string;
project_id: string;

View File

@@ -19,12 +19,12 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { api, ApiError } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types';
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
import { cn } from '@/lib/utils';
@@ -110,6 +110,16 @@ export function ProjectSidebar() {
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
// Work-at-risk dialog: shown when a delete is blocked (409) because the
// session's worktree holds uncommitted/unpushed/unmerged work.
const [riskState, setRiskState] = useState<{
sessionId: string;
projectId: string;
name: string;
message: string;
reports: WorktreeRiskReport[];
} | null>(null);
const [riskBusy, setRiskBusy] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
@@ -174,16 +184,81 @@ export function ProjectSidebar() {
}
}
async function handleDeleteSession(sessionId: string, projectId: string) {
async function handleDeleteSession(
sessionId: string,
projectId: string,
name: string,
force = false,
) {
try {
await api.sessions.remove(sessionId);
await api.sessions.remove(sessionId, force);
// Server publishes session_deleted via WS; useUserEvents delivers it.
setRiskState(null);
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
// 409 => the server's work-loss guard blocked the delete. Open the
// work-at-risk dialog with the per-worktree reports instead of toasting.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body && typeof err.body === 'object' && 'reports' in err.body
) {
const body = err.body as { error?: string; reports?: WorktreeRiskReport[] };
setRiskState({
sessionId,
projectId,
name,
message: body.error ?? 'This session has work at risk.',
reports: body.reports ?? [],
});
return;
}
toast.error(err instanceof Error ? err.message : 'failed to delete session');
}
}
// Stash the worktree's uncommitted changes (recoverable), then re-attempt the
// delete. If unpushed/unmerged commits remain, the retry 409s again and the
// dialog re-renders with the narrowed risk.
async function handleStashAndRetry() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
const { results } = await api.sessions.worktreeStash(riskState.sessionId);
const failed = results.find((r) => r.error);
if (failed) {
toast.error(`stash failed: ${failed.error}`);
return;
}
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stash failed');
} finally {
setRiskBusy(false);
}
}
// Explicit, destructive override — deletes despite work at risk.
async function handleForceDelete() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true);
} finally {
setRiskBusy(false);
}
}
// Route the user to commit it themselves — never auto-commit. Opens the
// session workspace where they can use a terminal or agent pane.
function handleGoCommit() {
if (!riskState) return;
const sessionId = riskState.sessionId;
setRiskState(null);
navigate(`/session/${sessionId}`);
toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.');
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
@@ -216,6 +291,20 @@ export function ProjectSidebar() {
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
// Work-at-risk dialog framing. The server returns 409 in two distinct
// situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or
// (2) it couldn't verify (BooCoder down/errored → reports is empty). These
// are different user stories — "your work is in danger" vs "the checker is
// offline" — so the dialog must not show one generic message for both.
const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? [];
const verifyFailed = riskState !== null && atRiskReports.length === 0;
const anyDirty = atRiskReports.some((r) => r.dirty);
// Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is
// all that remains (e.g. after a stash cleared the dirty changes), the dialog
// explains why it re-blocked and hides the Stash button so it doesn't look
// like stash "didn't work".
const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0);
return (
<aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between">
@@ -499,7 +588,7 @@ export function ProjectSidebar() {
const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
}
setDeleteConfirm(null);
}}
@@ -509,6 +598,77 @@ export function ProjectSidebar() {
</div>
</DialogContent>
</Dialog>
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
</DialogTitle>
<DialogDescription>
{verifyFailed ? (
<>
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
fine, but it couldn&apos;t be checked only force-delete if you&apos;re sure.
</>
) : anyDirty && anyCommits ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes <em>and</em> commits that aren&apos;t pushed or merged. Stash clears the
changes (recoverable), but the commits will still block push them or force-delete.
</>
) : anyDirty ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
</>
) : (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
aren&apos;t pushed or merged. Stashing won&apos;t recover these push them, or
force-delete.
</>
)}
</DialogDescription>
</DialogHeader>
{!verifyFailed && (
<div className="flex flex-col gap-2 py-1 text-sm">
{atRiskReports.map((r) => (
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
{r.branch || r.worktreePath}
</div>
<ul className="mt-1 list-disc pl-5 text-foreground/90">
{r.error && <li className="text-destructive">git error: {r.error}</li>}
{r.dirty && <li>uncommitted changes</li>}
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
</ul>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
Cancel
</Button>
{!verifyFailed && (
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
Commit&hellip;
</Button>
)}
{!verifyFailed && anyDirty && (
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
Stash &amp; delete
</Button>
)}
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
Force delete
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}