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
5 changes: 5 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2616,6 +2616,11 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
the message until the transcript proves it. Next-turn delivery and an explicit Send
now action claim the same server ID. Connection loss never resumes automatic input. Previously
held browser messages retain their explicit Send now/Discard recovery.
Any other follow-up's exact `sent` or `discarded` receipt is kept the same way (ten minutes, 1024,
same browser resume ID and device, local machine only) and replayed with `resumed: true` on its
browser's reattach, or to that browser's already attached successor when the Enter's reply comes
after the reload. It settles only that exact ID's saved copy: an uncertain copy, which ages out as
`dismissed`, stays unconfirmed, and nothing is typed again (#2421).
- Not connected, the one word `Reconnecting…` (`Connecting…` before this page's first connection to
the PC's socket) is the placeholder while the box is empty and moves into the status content once
there is a draft (`composerStatusHint`), on a phone too; it says no more (nothing is queued, and
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/pending-receipt-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- A message that went to the terminal no longer stays "Not confirmed" when the connection dropped
before its receipt arrived: the browser that sent it gets the receipt back when it reconnects or
reloads, within ten minutes. Nothing is sent again, and a message whose delivery was never
confirmed stays unconfirmed.
44 changes: 44 additions & 0 deletions server/core/pending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,50 @@ it.each(["delivery first", "withdrawal first", "ambiguous delivery first", "ambi
}
});

it.each(["same browser", "no device", "wrong browser", "wrong device", "read-only", "revoked", "no resume id", "expired", "uncertain",
"settled after reattach", "late wrong browser", "late wrong device", "late read-only", "late revoked"] as const)(
"replays a follow-up's exact receipt only to the browser that sent it (#2421): %s", (name) => {
// "late": the reload attaches before the Enter's reply settles the send, so the receipt is pushed to it
const late = name === "settled after reattach" || name.startsWith("late ");
const scenario = name.replace(/^late /, "");
let now = Date.now();
const clock = spyOn(Date, "now").mockImplementation(() => now);
try {
const token = crypto.randomUUID(), frames = new Map<Client, ServerMessage[]>(), clients = new Set<Client>(), revoked = new Set<Client>();
const client = (deviceId: string | undefined, receiptResumeId: string | undefined, readOnly = false): Client => {
const output: ServerMessage[] = [];
const owner = { data: connectionData({ deviceId, readOnly }), getBufferedAmount: () => 0,
send: (value: string) => { output.push(JSON.parse(value)); return 1; } } as Client;
owner.data.attached.set("p", { keepSize: false, receiptResumeId });
clients.add(owner); frames.set(owner, output); return owner;
};
const device = scenario === "no device" ? undefined : "device";
const first = client(device, scenario === "no resume id" ? undefined : token);
const delivery = createPendingDelivery({
options: {}, clients, attachments: new Map(), serialize: async (_pane, task) => task(), lastTyped: new Map(),
sight: () => { throw new Error("a receipt replay takes no input lease"); },
submitText: async () => { throw new Error("a receipt replay never types"); },
authorizeSocket: (owner) => { if (revoked.has(owner)) throw new Error("revoked"); },
clientSnapshot: async () => ({ panes: [] }) as unknown as SessionSnapshot,
omo: { sessionOf: () => null }, SUBMIT_DEADLINE_MS: 45_000, SUBMIT_DELAY_MS: 0, TYPED_SETTLE_MS: 0,
});
const item = delivery.pending.enqueue(first, "p", 1, "follow-up", null, { agent: "codex", terminalId: "p", session: null });
expect(delivery.pending.claim(item, false)).toBe(true);
// an Enter nobody confirmed leaves an uncertain copy, which only ever ages out as dismissed
if (scenario === "uncertain") delivery.pending.settle(item, { code: "submit_changed", message: "Enter was not confirmed" }, true);
else if (!late) delivery.pending.settle(item);
clients.delete(first); delivery.closeConnection(first);
if (scenario === "uncertain") { now += 60_001; delivery.pending.expire(); }
if (scenario === "expired") now += 10 * 60 * 1000 + 1;
const resumed = client(scenario === "wrong device" ? "other" : device, scenario === "wrong browser" ? crypto.randomUUID() : token, scenario === "read-only");
if (scenario === "revoked") revoked.add(resumed);
delivery.attached(resumed, "p");
if (late) delivery.pending.settle(item);
const removed = frames.get(resumed)!.flatMap((frame) => frame.type === "pending-messages" ? frame.removed ?? [] : []);
expect(removed).toEqual(["same browser", "no device", "settled after reattach"].includes(scenario) ? [{ id: item.message.id, outcome: "sent", resumed: true }] : []);
} finally { clock.mockRestore(); }
});

it.each(["before close", "after close", "overlapping reload", "same live browser", "no device", "read-only", "detach", "wrong browser", "wrong device", "revoked", "expired", "pending cap", "completed cap", "confirmation budget"] as const)(
"resumes only exact, authorized Pi presentation receipts: %s", async (when) => {
const root = mkdtempSync(join(tmpdir(), "ovis-pi-resume-")); roots.push(root);
Expand Down
46 changes: 42 additions & 4 deletions server/core/pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { closeBackslash, closeMention, type createSubmitText, freeClaudeBox, fre
import { type Client, connectionOf, send } from "./connections.ts";
import { ATTACH_HELD_MESSAGE } from "./terminal-process.ts";
import type { AttachmentView, TerminalSighting } from "./terminals.ts";
import type { ClientMessage, HerdrPane, PendingMessage, SessionSnapshot } from "../../shared/protocol.ts";
import type { ClientMessage, HerdrPane, PendingMessage, PendingRemoval, SessionSnapshot } from "../../shared/protocol.ts";
import { typingGuards } from "../input-guard.ts";
import { claudeForegroundSession, isClaudeProcess } from "../claude-store.ts";
import { HerdrError, paneProcessInfo, paneRead, paneSendKeys, paneSendText, type ProcessInfo } from "../herdr/client.ts";
Expand Down Expand Up @@ -112,7 +112,11 @@ export function createPendingDelivery({ options, clients, attachments, sight, se
return typingGuards.pendingLease(sight(client, paneId).facts());
});
const pending = new PendingInputs<PendingOwner, PendingLease | null>((owner, paneId, messages, removed) => {
if (isClient(owner)) { send(owner, { type: "pending-messages", pane_id: paneId, messages, ...(removed ? { removed } : {}) }); return; }
if (isClient(owner)) {
send(owner, { type: "pending-messages", pane_id: paneId, messages, ...(removed ? { removed } : {}) });
keepReceipts(owner, paneId, messages, removed);
return;
}
// a first message: every connection attached to the pane sees it, and keeps the receipt once it went
if (removed) firstReceipts.set(paneId, [...(firstReceipts.get(paneId) ?? []), ...removed].slice(-FIRST_MESSAGE_RECEIPTS));
for (const client of clients) {
Expand Down Expand Up @@ -158,13 +162,18 @@ export function createPendingDelivery({ options, clients, attachments, sight, se
const PI_RESUME_MS = 10 * 60 * 1000, PI_RESUME_LIMIT = 1024;
const livePiOwner = (owner: Client): boolean => clients.has(owner) && !connectionOf(owner).closing;
const piCompleted: Array<{ paneId: string; key: string; id: string; at: number }> = [];
/** a follow-up's sent or discarded receipt, kept for its browser's return (#2421): the same bounds and binding as Pi's */
const completed: Array<{ paneId: string; key: string; id: string; outcome: "sent" | "discarded"; at: number }> = [];
/** the resume key each follow-up's browser had when the bridge took it, by connection and message id */
const receiptKeys = new WeakMap<Client, Map<string, string>>();
const resumeKey = (client: Client, paneId: string): string | undefined => {
const id = client.data.attached.get(paneId)?.receiptResumeId;
return id ? JSON.stringify([client.data.deviceId ?? null, id]) : undefined;
};
const prunePi = (): void => {
const cutoff = Date.now() - PI_RESUME_MS;
while (piCompleted.length && (piCompleted[0]!.at < cutoff || piCompleted.length > PI_RESUME_LIMIT)) piCompleted.shift();
while (completed.length && (completed[0]!.at < cutoff || completed.length > PI_RESUME_LIMIT)) completed.shift();
let count = 0;
for (const [paneId, items] of piTyped) {
const kept = items.filter((item) => {
Expand Down Expand Up @@ -883,13 +892,42 @@ export function createPendingDelivery({ options, clients, attachments, sight, se
item.owner = client;
rebound = true;
}
const removed = piCompleted.filter((item) => item.paneId === paneId && item.key === key)
.map(({ id }) => ({ id, outcome: "consumed" as const }));
const removed = [
...piCompleted.filter((item) => item.paneId === paneId && item.key === key).map(({ id }) => ({ id, outcome: "consumed" as const })),
...completed.filter((item) => item.paneId === paneId && item.key === key).map(({ id, outcome }) => ({ id, outcome, resumed: true as const })),
];
if (removed.length) send(client, { type: "pending-messages", pane_id: paneId, messages: [], removed });
} catch { /* failed authorization cannot resume even presentation */ }
}
return rebound;
}
/**
* Keeps a follow-up's sent or discarded receipt for its browser's return: a socket that drops before the
* receipt reaches it leaves the browser's copy unconfirmed. Only an exact outcome is kept (never a dismissed
* uncertain copy), bound to the device and browser that sent it, and it is presentation only: nothing is typed.
*/
function keepReceipts(owner: Client, paneId: string, messages: PendingMessage[], removed: readonly PendingRemoval[] | undefined): void {
const key = resumeKey(owner, paneId);
const keys = receiptKeys.get(owner) ?? new Map<string, string>();
if (key) for (const message of messages) if (!keys.has(message.id)) keys.set(message.id, key);
for (const item of removed ?? []) {
const kept = keys.get(item.id);
keys.delete(item.id);
if (!kept || (item.outcome !== "sent" && item.outcome !== "discarded")) continue;
completed.push({ paneId, key: kept, id: item.id, outcome: item.outcome, at: Date.now() });
// a reload can attach its successor before the Enter's reply settles the send
for (const successor of clients) {
if (successor === owner || !livePiOwner(successor) || !successor.data.attached.has(paneId)
|| resumeKey(successor, paneId) !== kept || connectionOf(successor).mode === "observe") continue;
try {
authorizeSocket(successor);
send(successor, { type: "pending-messages", pane_id: paneId, messages: [], removed: [{ id: item.id, outcome: item.outcome, resumed: true }] });
} catch { /* failed authorization cannot resume even presentation */ }
}
}
if (keys.size) receiptKeys.set(owner, keys); else receiptKeys.delete(owner);
prunePi();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/** A connection that attached the pane: it sees the pane's first messages and their receipts, and may be the one to type them. */
function attached(client: Client, paneId: string): void {
prunePi();
Expand Down
40 changes: 40 additions & 0 deletions server/pending-input.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,46 @@ async function setup(label: string, agent = "claude", screen = "› Message\n",
return { screenFile, pane, port: server.port, server, socket, bytes, waitBytes, state, showScreen, queue, removed, another, replacementLog };
}

describe("resumable follow-up receipts (#2421)", () => {
it.each(["sent", "discarded"] as const)("replays a Codex follow-up's exact %s receipt to its returning browser without typing again", async (outcome) => {
const f = await setup(`codex-resume-${outcome}`, "codex");
const token = crypto.randomUUID(), from = f.socket.seen.length;
f.socket.send({ type: "attach", pane_id: f.pane, cols: 100, rows: 30, receipt_resume_id: token });
await f.socket.wait((frame) => frame.type === "input-ready" && frame.pane_id === f.pane, from);
const row = await f.queue(980, "follow-up");
if (outcome === "sent") { await f.state("idle"); await f.waitBytes(`${paste(row.text)}\r`); }
else {
f.socket.send({ type: "pending-action", id: 981, pane_id: f.pane, pending_id: row.id, action: "discard" });
expect((await f.socket.action(981)).ok).toBe(true);
}
await f.removed(row.id, outcome);
const bytes = f.bytes();
await f.socket.disconnect();
const resumed = await f.another(false);
await resumed.attach(f.pane, false, token);
const replay = await f.removed(row.id, outcome, resumed);
expect(replay.removed).toContainEqual({ id: row.id, outcome, resumed: true });
expect(f.bytes()).toBe(bytes);
}, 20_000);

it("gives no receipt to a browser that attaches with another resume id", async () => {
const f = await setup("codex-resume-other", "codex");
const from = f.socket.seen.length;
f.socket.send({ type: "attach", pane_id: f.pane, cols: 100, rows: 30, receipt_resume_id: crypto.randomUUID() });
await f.socket.wait((frame) => frame.type === "input-ready" && frame.pane_id === f.pane, from);
const row = await f.queue(983, "follow-up");
await f.state("idle"); await f.waitBytes(`${paste(row.text)}\r`);
await f.removed(row.id, "sent");
await f.socket.disconnect();
const other = await f.another(false);
await other.attach(f.pane, false, crypto.randomUUID());
// frames arrive in order: a receipt sent on this attach would come before the pong
other.send({ type: "ping", id: 984 });
await other.wait((frame) => frame.type === "pong" && frame.id === 984);
expect(other.seen.some((frame) => frame.type === "pending-messages" && frame.removed?.some((item: { id: string }) => item.id === row.id))).toBe(false);
}, 20_000);
});

describe("Ovis-origin Pi transcript receipts", () => {
const piLine = (id: string, parentId: string | null, role: string, text: string, stamp = Date.now()) => JSON.stringify({
type: "message", id, parentId, timestamp: new Date().toISOString(), message: { role, content: [{ type: "text", text }], timestamp: stamp },
Expand Down
6 changes: 5 additions & 1 deletion shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,10 @@ export interface PromptAnswer {
/** A connection's authority over the shared ptys: `interact` types and resizes, `observe` only watches. */
export type ClientRole = "interact" | "observe";

/** A pending message's outcome. `resumed`: replayed after a reconnect, only to the device and browser
* (`receipt_resume_id`) that sent it, for its exact id; it settles a saved copy and never sends input. */
export interface PendingRemoval { id: string; outcome: "sent" | "discarded" | "consumed" | "dismissed"; resumed?: true }

/** A follow-up accepted by this bridge, scoped to the connection that submitted it. */
export interface PendingMessage {
id: string;
Expand Down Expand Up @@ -1040,7 +1044,7 @@ export type ServerMessage =
/** Owner-only receipt and state; only an explicit removed outcome permits deleting a retained client message.
* `dismissed` removes an uncertain copy, never proves withdrawal or delivery. Existing clients
* remove owned IDs for any explicit outcome; Edit restores only `discarded`. */
| { type: "pending-messages"; pane_id: string; messages: PendingMessage[]; removed?: Array<{ id: string; outcome: "sent" | "discarded" | "consumed" | "dismissed" }> }
| { type: "pending-messages"; pane_id: string; messages: PendingMessage[]; removed?: PendingRemoval[] }
| { type: "secret-result"; id: number; pane_id: string; ok: boolean; code?: string }
/** agent-status push for ANY pane, attached or not (server-side status collector) */
| { type: "pane-status"; pane_id: string; agent_status: AgentStatus; finish_id?: number; finish_epoch?: string; /** a pane's running background tasks (OmO's, or a Claude Code pane's subagents and commands), when the frame is about them: it changes no status */ background_tasks?: number; /** every frame says it: absent, the pane does not wait on its turn's background work */ background_wait?: true; /** a pane's plan, when the frame is about it (null: it keeps none now); it changes no status */ plan?: PlanSummary | null }
Expand Down
Loading
Loading