diff --git a/AGENTS.md b/AGENTS.md index 5c7d26bae..e4cd9cf91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` (`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 @@ -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 (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: `, 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 + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f48852f..9ef4e67fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `, 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 `/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. @@ -63,6 +64,8 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - **Ask forms preserve preformatted text.** Examples inside `
` 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.
diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts
index 3d46eabe0..c275b0e62 100644
--- a/app/api/sessions/[id]/route.ts
+++ b/app/api/sessions/[id]/route.ts
@@ -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. */
@@ -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 });
diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx
index 151225dc7..763c6e851 100644
--- a/components/ChatWindow.tsx
+++ b/components/ChatWindow.tsx
@@ -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,
@@ -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) => (
-              
+              
             ))}
 
             
diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs index 7f864ebd4..1149e091c 100644 --- a/components/MessageView.test.mjs +++ b/components/MessageView.test.mjs @@ -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) => { diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 2c42289af..4591bff56 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -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"; @@ -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") @@ -584,6 +586,27 @@ export function HeldMessageView({ held, hostLabel, onDelete }: { {status} + {failed && onRetry && <> + + { setConfirmingRetry(false); onRetry(held.id); }} + /> + } {canDelete && <>