Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c8839ff
feat(outbox): retry a held message that was not sent
andrebrait Nov 13, 2026
d64f4f6
fix(outbox): retry removes the failed message in its own session; doc…
andrebrait Nov 13, 2026
f17bc44
fix(outbox): Retry removes the original only after the server stored …
andrebrait Nov 13, 2026
2c25507
fix(outbox): a Retry's copy replaces its original atomically and surv…
andrebrait Nov 14, 2026
cb7de12
docs(outbox): distinguish failed retries from transient backoff
andrebrait Nov 14, 2026
83f61c0
test: cover retry replacement on closed-page delivery
andrebrait Nov 14, 2026
8752cec
fix(outbox): deduplicate retry lineages across clients
andrebrait Nov 14, 2026
89b098b
fix(outbox): dedupe retries after winner expiry
andrebrait Nov 14, 2026
eaf5763
fix(outbox): retain retry mappings until session deletion
andrebrait Nov 14, 2026
68e52ff
docs(outbox): clarify deleted-session metadata cleanup
andrebrait Nov 14, 2026
b91868a
fix(outbox): coalesce browser-only retries
andrebrait Nov 14, 2026
8fb1045
fix(outbox): redrive reused retry copies
andrebrait Nov 14, 2026
d082379
fix(outbox): persist browser-only retry root identities
andrebrait Nov 14, 2026
9ed2179
fix(outbox): promote server-known retry roots
andrebrait Nov 14, 2026
46532b7
fix(outbox): preserve retry identity across terminal dispositions
andrebrait Nov 14, 2026
4b6c6a8
fix(outbox): reconcile settled retry copies
andrebrait Nov 14, 2026
6e33e1e
fix(outbox): wait on settled retry reconciliation
andrebrait Nov 14, 2026
bc67d90
docs(outbox): document settled retry promotion
andrebrait Nov 14, 2026
aee8412
fix(outbox): refresh reconciliation after peer cleanup
andrebrait Nov 14, 2026
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
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,7 @@ executor-style promises instead. Server code may use them freely.
- Event connections own their readiness waits: replacement and unmount settle the old wait as closed, clear its timers/coalesced work and close its observer. Late continuations must not attach streams to an unmounted hook or let a stale connection authorize a new operation. Keep lifetime cleanup separate from the mount-load latch so StrictMode setup/cleanup replay still cleans up observers attached afterward.
A new chat's first prompt is the exception to "abandoned readiness": when its chat unmounts while omp spawns, `handleSend` still sends the prompt, opens no observer, skips host-tool/roster setup and returns `accepted`. An existing session's messages need no exception: they go through the message outbox (below), which delivers them with no tab involved.
Ordinary `ChatInput.onSend` returns `Promise<PromptSubmissionResult>` (`accepted` / `rejected` / `superseded`). With `outbox` (an existing session) the composer clears only once `onSend` (or `onSteer`/`onFollowUp`) reports the message stored in the browser's outbox, and only if it still holds what was sent; a refusal leaves it in place. For a new chat only rejection restores composed text/file contents and images through `recoverDraft`; its receipt supplies the promoted real-session draft key when the new chat became a session, otherwise the composer uses the originating key captured before submission. Superseded failures must not reinsert a processed turn or reset a newer run. The composer owns recovery; `useAgentSession.handleSend` must not separately insert failed-send text and duplicate it.
Retry roots may be server-known or browser-only. Server-known roots are hidden only on a server-confirmed `replaces`; a browser-only failed root is retired only after a valid accepted receipt for its committed, same-session exact-intent copy, even if a lost-response replay is an empty tombstone without `replaces`. Concurrent same-root holds share one IndexedDB copy and each caller gets its durable-add acknowledgement. Refusals preserve the root; cross-session and unrelated same-text entries remain.
- **In-session branch** (Continue button / BranchNavigator): reads a historical view of the entry tree within the same file; it does not change the live agent's context. Multiple entries share the same `parentId`. Switching between them calls `/api/sessions/[id]/context?leafId=` through `handleLeafChange`; never use that read-only path to implement Edit.

### ToolCall field normalization
Expand Down Expand Up @@ -1298,7 +1299,13 @@ during the wait.
settled ids prevent late ACKs from resurrecting delivered entries. States:
Sending (browser, locked while its first-leg command may be in flight),
Waiting to send / Waiting for <host> (Delete), Delivering (locked),
Not sent (Delete). The delivery mode is a read-only label. Delete
Not sent (Retry, Delete). Retry (`retryHeldMessage`) holds a new entry with the same text, images, uploads and
mode and `replaces: <failed id>`, persisted in IndexedDB and sent with `outbox_add`. The server retires a failed
original of the same session in the transaction that stores the copy, and the worker (or `sw.js`) deletes the
browser's row of it in the commit that deletes the stored copy; a refused copy leaves it. The `outboxStored` it
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
posts carries `replaces`, and the hook marks that original represented at once. Retry holds no copy while one
naming the id waits in IndexedDB (true after a remount, a reload or a closed page) or once one was stored, and
never sends `outbox_remove`. A `mayHaveBeenSent` entry asks first. The delivery mode is a read-only label. Delete
(`deleteHeldMessage`) awaits the IndexedDB delete commit for a browser-held
entry; a server entry sends `outbox_remove`, refused with 409
`outbox_dispatched` once delivering. A late commit consumes only the original
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

- **A distinct ompweb icon.** The rounded π inside an open orbit retains the pink–purple–cyan palette. The sidebar logo, SVG/PNG artwork, favicons, installed-app icons and tray icon now use the same mark.
- **Upgrades check the session omp before they switch.** `ompweb preflight` resolves omp exactly as the host does for a new session (`OMP_WEB_OMP_BIN` or this version's downloaded `ompweb-omp`), starts it headless with `--no-session`, and waits up to 30 s for its hello: it prints `preflight ok: <omp> <version>`, or exits 1 naming the binary and why (not found, not executable, exited with its code, or no hello with omp's last stderr). The host updater and the web app's in-app update run the new release's preflight in the live environment before they stop anything, and abort with the current install still running when it fails. Before the preflight, both run the new release's `ompweb fetch-omp`, so its runner is in place first. A release without the preflight command is checked only if it ships `ompweb-omp`; then the update fails.
- **Retry a message that was not sent.** A held **Not sent** bubble has a **Retry** button beside **Delete**. Retry stores a new copy with the same text, images, attached files and delivery mode. The server atomically retires a root it knows; for a browser-only failure, the worker removes only the exact same-session local root and siblings after a valid acceptance, even if a lost-response replay returns an empty tombstone with no `replaces`. A refusal keeps the failed message. Concurrent same-root retries share one browser copy, including across tabs. If the failed message may already have reached omp, Retry asks first, since sending it again can make it run twice.
- **Rename the cluster.** Settings → Hosts shows the cluster's name with a **Rename cluster** button; the sidebar group follows. The cluster is still created as "Cluster" when the first host joins, so rename it afterwards. `PATCH /api/hosts { poolId, label }` renames a pool without replacing the pool registry.
- **Cluster members use this machine's omp configuration.**
**Before upgrading:** every cluster member needs an ompweb host daemon at link protocol 10. Until a member's daemon is updated, sessions cannot start there (the start is refused and names the update), cluster placement skips it, a host below protocol 10 cannot join, and Settings → Hosts says it needs an update. Once its daemon is updated, every host already in the first pool receives this machine's configuration, secrets included (API keys, MCP environments and headers, tokens), automatically and without a confirmation having been shown, and its own omp configuration is replaced until it leaves the cluster; it is kept in `<agent dir>/omp-web/own-config/` on the member and put back on leaving. To avoid that for a host, remove it from the first pool (`PUT /api/hosts`, or editing `~/.omp/agent/omp-web/pools.json`) before updating its daemon. Update the web server first, then the members.
Expand Down Expand Up @@ -63,6 +64,8 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

- **Ask forms preserve preformatted text.** Examples inside `<pre>` blocks keep their line breaks and indentation, including inside choice labels.
- **Linked sessions survive brief reconnects.** A temporary host outage during recovery no longer closes the shared session channel. Cluster readiness no longer regresses when an older configuration reply arrives, and auth-broker readiness waits begin when its process has spawned.
- **Retry deduplication survives long browser-offline periods.** The server keeps each session's content-free canonical retry mapping until successful session deletion; ordinary receipts and aliases retain the existing eight-day expiry, and deletion leaves pending entries and pinned GitHub receipts untouched.
- **Concurrent retries cannot duplicate a browser-only failure.** Two tabs or workers retrying the same failed root now reuse one exact-intent IndexedDB copy. The accepted receipt retires only its same-session proven root and siblings; an empty tombstone after a lost response is sufficient, while refusals, cross-session references and unrelated same-text entries remain intact.
- The repository moved to ompweb-dev/ompweb; old links redirect.
- **omp updates with ompweb: the omp row no longer runs `omp update`.** Each ompweb release ships its own omp runner, so Settings → Hosts shows the host's omp version with "Updated with ompweb" and no update button. Hosts refuse an omp update request, and `/api/omp-update` keeps only `restart`.
- **Sorting, pinning and reordering work in the cluster's sidebar group.** The group listed the cluster's shared projects first whatever the sort, and Move up/down, drag and pins only worked among projects of the same machine, so a pinned or alphabetically earlier project of a member could not move above a shared one. The group is now one list: the chosen sort, pins and manual order apply across shared projects and every member's own projects.
Expand Down
7 changes: 7 additions & 0 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { invalidateHostSessionList } from "@/lib/hosts/session-index";
import { invalidateSessionCaches } from "@/lib/session-reader";
import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
import { clearExitedRpcSession, getLiveRpcSessionIds, getRpcSession, WebRpcError } from "@/lib/rpc-manager";
import { forgetSessionReplacementMappings } from "@/lib/message-outbox";

/** Stable, client-safe error body for catch-all handlers: details go to the
* server log only, never to the browser. */
Expand Down Expand Up @@ -134,6 +135,12 @@ export async function DELETE(
// owns the file and would clobber it on its next flush.
const result = await resolved.host.sessionOps.deleteSession(id, getLiveRpcSessionIds());
if (!result.ok) return opErrorResponse(result);
// Remove only this deleted session's content-free retry-lineage metadata.
try {
forgetSessionReplacementMappings(id);
} catch (error) {
console.warn(`[omp-web] could not clear deleted-session retry metadata: ${error instanceof Error ? error.message : String(error)}`);
}
invalidateHostSessionList(resolved.host.id);
clearExitedRpcSession(id);
return Response.json({ ok: true, ...result.value });
Expand Down
4 changes: 2 additions & 2 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio
sessionIdRef, messagesEndRef, scrollContainerRef, holdScrollPosition,
handleSend, handleAbort, handleFork, handleEdit, handleModelChange, retrySession,
handleSteer, handleFollowUp, handleAbortCompaction, handleCompact,
heldMessages, deleteHeldMessage,
heldMessages, deleteHeldMessage, retryHeldMessage,
removeQueuedMessage, promoteQueuedToSteer,
handleBuiltinSlashCommand, togglePreCompactionHistory,
handleThinkingLevelChange, handleFastModeChange, handleSlowModeChange, handleCycleModel, handleCycleThinkingLevel, handleAbortRetry, loadSlashCommands,
Expand Down Expand Up @@ -1340,7 +1340,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio

{/* After the live turn, in submission order: what omp does not have yet. */}
{heldMessages.map((held) => (
<HeldMessageView key={held.id} held={held} hostLabel={hostLabel} onDelete={deleteHeldMessage} />
<HeldMessageView key={held.id} held={held} hostLabel={hostLabel} onDelete={deleteHeldMessage} onRetry={retryHeldMessage} />
))}

<div ref={messagesEndRef} />
Expand Down
20 changes: 20 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ test("held messages name every delivery leg and only offer Delete before dispatc
}
});

test("only a failed held message offers Retry, and one that may have reached omp asks first", async () => {
const base = { id: "held", mode: "prompt", message: "Again.", createdAt: Date.now(), local: false };
for (const state of ["sending", "waiting_host", "held", "delivering"]) {
assert.equal(renderToStaticMarkup(React.createElement(HeldMessageView, { held: { ...base, state }, onRetry() {} })).includes("Retry sending this message"), false, state);
}
const retried = [];
const plain = render(React.createElement(HeldMessageView, { held: { ...base, state: "failed" }, onRetry: (id) => retried.push(id) }));
await act(async () => { fireEvent.click(plain.getByRole("button", { name: "Retry sending this message" })); });
assert.deepEqual(retried, ["held"]);
assert.equal(plain.queryByRole("dialog"), null);
plain.unmount();
const uncertain = render(React.createElement(HeldMessageView, { held: { ...base, state: "failed", mayHaveBeenSent: true }, onRetry: (id) => retried.push(id) }));
await act(async () => { fireEvent.click(uncertain.getByRole("button", { name: "Retry sending this message" })); });
const dialog = await waitFor(() => uncertain.getByRole("dialog"));
assert.match(dialog.textContent, /run it twice/);
assert.deepEqual(retried, ["held"]);
await act(async () => { fireEvent.click(uncertain.getAllByRole("button", { name: "Retry" }).at(-1)); });
assert.deepEqual(retried, ["held", "held"]);
});

test("Delete confirmation never claims omp has not seen a message that may have been sent, nor cancellation", async () => {
const base = { id: "held", mode: "prompt", message: "Keep this safe.", createdAt: Date.now(), local: false, state: "failed" };
const dialogText = async (held) => {
Expand Down
29 changes: 26 additions & 3 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { memo, useState, useId, useRef, useEffect, useLayoutEffect, useMemo, useCallback, useContext, type ComponentProps } from "react";
import { Copy, Check, GitFork, CornerUpLeft, ChevronRight, ChevronDown, Brain, EyeOff, CircleAlert, CircleSlash, Clock, LoaderCircle, FileText, Search, FileEdit, Terminal, CheckSquare, Bot, Code2, Globe, MessagesSquare, Wrench, Volume2, Square, Trash2, Paperclip } from "lucide-react";
import { Copy, Check, GitFork, CornerUpLeft, ChevronRight, ChevronDown, Brain, EyeOff, CircleAlert, CircleSlash, Clock, LoaderCircle, FileText, Search, FileEdit, Terminal, CheckSquare, Bot, Code2, Globe, MessagesSquare, Wrench, Volume2, Square, Trash2, Paperclip, RotateCcw } from "lucide-react";
import { MarkdownBody } from "./MarkdownBody";
import { WorkspaceFileContext } from "@/lib/workspace-file-context";
import { AgentLinkContext, agentLinkIds } from "../lib/agent-links";
Expand Down Expand Up @@ -531,14 +531,16 @@ const heldActionStyle = {
} as const;

/** A message omp-web holds until omp has it (docs/specs/2026-11-04-message-outbox-design.md O1): dimmed, after the
* live turn, naming the leg it waits on. Delete only while nothing of it can have reached omp. */
export function HeldMessageView({ held, hostLabel, onDelete }: {
* live turn, naming the leg it waits on. Delete only while nothing of it can have reached omp; Retry a failed one. */
export function HeldMessageView({ held, hostLabel, onDelete, onRetry }: {
held: HeldMessage;
hostLabel?: string;
onDelete?: (id: string) => void;
onRetry?: (id: string) => void;
}) {
const { t, locale } = useI18n();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [confirmingRetry, setConfirmingRetry] = useState(false);
const failed = held.state === "failed";
const status = failed
? held.mayHaveBeenSent ? t("heldMessage.mayHaveBeenSent") : held.error ? t("heldMessage.notSentReason", { reason: held.error }) : t("heldMessage.notSent")
Expand Down Expand Up @@ -584,6 +586,27 @@ export function HeldMessageView({ held, hostLabel, onDelete }: {
<StatusIcon size={12} strokeWidth={1.8} className={failed || waits ? undefined : "activity-row-spinner"} aria-hidden />
{status}
</span>
{failed && onRetry && <>
<button
type="button"
className="ui-focus-ring"
style={heldActionStyle}
onClick={() => held.mayHaveBeenSent ? setConfirmingRetry(true) : onRetry(held.id)}
aria-label={t("heldMessage.retryTitle")}
>
<RotateCcw size={11} strokeWidth={1.8} aria-hidden />
{t("heldMessage.retry")}
</button>
<ConfirmDialog
open={confirmingRetry}
onOpenChange={setConfirmingRetry}
title={t("heldMessage.retryTitle")}
description={t("heldMessage.retryConfirmMayHaveBeenSent")}
confirmLabel={t("heldMessage.retry")}
cancelLabel={t("chatInput.cancel")}
onConfirm={() => { setConfirmingRetry(false); onRetry(held.id); }}
/>
</>}
{canDelete && <>
<button type="button" className="ui-focus-ring" style={heldActionStyle} onClick={() => setConfirmingDelete(true)} aria-label={t("heldMessage.deleteTitle")}>
<Trash2 size={11} strokeWidth={1.8} aria-hidden />
Expand Down
Loading
Loading