Read the conversation that asked before taking a lock on the one being answered in - #305
Read the conversation that asked before taking a lock on the one being answered in#305kevin9327 wants to merge 1 commit into
Conversation
…g answered in A delivery takes the run lock on the addressed Bot's conversation, and then reads the history of the conversation that ASKED — a different thread, which the lock never protected. Between the two sits the only `await` in the delivery that is not inside the `try` whose `finally` gives the lock back. `historyOrEmpty` answers a missing thread with nothing and rethrows everything else on purpose: a 500 from the platform means an outage or a bad key, not an empty conversation. So one 500, one expired key, one dropped connection, and the lock is held by a delivery that has already gone. Nothing renews it and nothing releases it, so it sits until the platform's own TTL — about two minutes — during which the person cannot start a run in that conversation at all. The hop's retry a minute later then collides with the lock it is still holding itself, gets null from `acquire`, reports the conversation as busy and spends one of its five attempts on that. So the same outage costs the person a working conversation and costs the hop an attempt, for a read that did not need the lock in the first place. Hoisted above the acquire. Nothing else moves: the ask is still appended with the platform's own run id, which is only known after the lock, and the history is of another conversation so reading it a moment earlier changes nothing about what the addressed Bot is shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The history read is the one call in a delivery that throws on a platform error, and it sat after the lock was acquired but before the try whose finally gives the lock back: a 500 from the platform leaked the lock for its full TTL. On a forward hop that lock is a scratch thread and the leak costs nothing; on a relay it is the asking conversation itself, so the person could not type for two minutes, the retry a minute later collided with the hop's own leftover hold and spent an attempt on it, and a relay that ran out of attempts vanished without a notice. The read is of the conversation that asked, which the lock — taken on the conversation being answered in — never protected. Hoisted above the acquire it fails before anything is held, and the happy path holds the lock for less of the turn too. Raised by kevin9327 on #290, who had the same reordering up as #305 against main; a test now pins the ordering.
…o the keyboard (#290) * Gate first sign-in behind a per-user onboarding wizard Two columns on users say where somebody is in first-run onboarding and when they finished; /api/me carries the status and one POST moves it. The app redirects an unfinished user to /onboarding — an animated three-step wizard — and an address the build does not know now goes home through the same gate instead of a bare 404. * Say work is offered aloud, so a sweep can start now instead of at its next poll The handoff queue was swept every two seconds, and a person is waiting through every hop, so each leg of a handoff cost up to a full interval doing nothing. Offering work now fires pg_notify with the kind as payload, inside the offering transaction where there is one so it fires on commit and never before, and any replica can listen on a dedicated connection and kick its sweep immediately. A notification is a latency optimisation, never a delivery mechanism: one lost in transit costs up to one poll interval, not the work, and the queue's tables stay the only truth. * Let a channel tell its members a turn is running in it A transient busy flag on the channel activity event, announced and never written: busy is a moment, not a fact about the channel, and a missed signal costs at most a stuck-looking dot until the next real event, never data. Two ways in. The server signals by thread (signalBusy) for the runs it can see — the runtime's lock acquire and release now carry an onRunBusy seam, so every run the platform processes lights its channel, including one whose tab has navigated away — and a scratch thread maps to no channel and signals nowhere, which is the point of a scratch thread. The browser signals by channel (POST /:channelId/busy) for the one thing the server cannot see, a person's own turn beginning, with a membership check so belonging to a channel is not something an outsider can probe for. * Relay a handoff answer back into the conversation that asked A forward hop used to answer in the addressed Bot's own channel with the person: two conversations for one question, and the answer landed somewhere they never asked anything. Now the hop runs in a scratch thread of the addressed Bot's own — minted per hop, never mapped to a channel, never shown — and what it said comes home through a second queued hop that has the asking Bot relay the answer, attributed, in the conversation the person is watching. The delivery gathers the Bot's words from the stream as it goes past, because the runner publishes the turn to the platform and the events are the one chance to hear it. The relay rides the same durable queue as the turn that produced it, so a pod dying between the two loses the relay to a retry rather than for ever; the answerIn marker that stops a failure notice recursing stops a relay relaying. Answers are clipped at 12k characters so a Bot that comes back with a book cannot swamp the relaying run's prompt, and a backwards hop reads only the tail of the asking conversation so relaying never grows slower with the channel. Wired in index.ts: the scratch thread replaces the direct-channel answerIn, the roster announcement resolves thread to channel and happens only when the turn said something, the asking channel is lit while a forward hop runs, and the queue's new offered-work notification kicks the sweep so a person is not waiting out a poll interval per leg. * Show a channel working on the roster while a turn runs in it Three bouncing dots badged on the channel's avatar, driven by the busy flag on the activity socket. Socket-only and transient on the summary type: the roster query never returns it, so it is undefined until a busy event arrives and drops whenever the roster refetches — the acceptable failure for a hint about a moment. The event patcher flips only the busy field: the spread that serves ordinary activity would carry the event's null message onto the row and wipe the preview, and busy is not activity, so the row does not re-sort either. The channel reports its own turns over the new busy endpoint, fire-and-forget, and deliberately does not clear on unmount: a turn keeps running server-side after the person leaves the channel, and the server clears it when the run's lock is released. * Keep an open channel current with turns nobody here streamed A relayed handoff answer runs on the server and lands in the thread with no browser attached; the transcript restored history once, on mount, and would show the new turn only after leaving and coming back. The chat now watches the roster's own channel-list cache — the sidebar updating and the transcript refreshing are one signal and cannot drift apart — and when this channel's lastMessageAt advances to a moment a Bot authored, the durable history is read again and messages whose ids the transcript has never seen are appended. Appended by id, not compared by length, because the stored read keeps only what the platform can parse and can be shorter than the screen while still holding the news. Retried briefly, since the roster is patched when the turn is on record with the runner and the platform's read can be a beat behind. The chat also reports its own turns to the busy endpoint, keyed on whether a turn is in flight, so the roster's working dots cover the one run the server cannot see begin. * Start a new chat from anywhere with Shift+N A small hotkey registry, one place on purpose: the binding a listener matches against and the combo the settings page shows are the same record, so the list under Preferences is what the keys actually do rather than what somebody remembered they did — each key drawn as its own keycap, symbols on a Mac and names elsewhere. Matching is exact rather than at-least, so Shift+N does not fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain Shift+letter combo is left alone while the focus is anywhere editable. Bound in _authed rather than _app, so a person on settings or admin can start a chat without first clicking back into the app frame. The new-channel composer's recipient picker takes focus on arrival, so Shift+N then typing a name is one motion. * Format the onboarding route The wizard shipped unformatted; biome's own output, no hand edits. * Read the asking conversation before taking the run's lock The history read is the one call in a delivery that throws on a platform error, and it sat after the lock was acquired but before the try whose finally gives the lock back: a 500 from the platform leaked the lock for its full TTL. On a forward hop that lock is a scratch thread and the leak costs nothing; on a relay it is the asking conversation itself, so the person could not type for two minutes, the retry a minute later collided with the hop's own leftover hold and spent an attempt on it, and a relay that ran out of attempts vanished without a notice. The read is of the conversation that asked, which the lock — taken on the conversation being answered in — never protected. Hoisted above the acquire it fails before anything is held, and the happy path holds the lock for less of the turn too. Raised by kevin9327 on #290, who had the same reordering up as #305 against main; a test now pins the ordering. * Refetch the roster as well as the user when onboarding completes Finishing the wizard navigates straight into the app, and the channel list — cached from before the wizard, or from a fetch that ran while it held the screen — could greet the person with an empty sidebar their reload then fixed. Seen once on a real deployment during review of #290 and not cleanly reproduced, so this is a hardening of the seam rather than a confirmed fix: both invalidations use refetchType "all" for the same reason, nothing is observing these queries while the wizard is up. * Say which wizard agents are examples, and unstick its two classes Two review findings on the wizard's roster step. The invented agents that top up a sparse deployment rendered identically to real ones, so "Support Agent" read as a Bot the deployment has; they are dimmed and labelled Example now. And max-w-lg had run together with overflow-hidden into a class that applied neither, which is why the roster cards drew wider than every other step. Also stops the onboarding store's comment promising that step is where the wizard resumes: it is stored and served but nothing resumes from it yet, and the comment now says so instead of describing a behaviour that does not exist. * Say in the changelog what changed, including reversing 0.0.5's relay stance Five user-visible changes were on this branch with no entry: the relay, the working indicator, the transcript catch-up, the onboarding wizard, and Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on the addressed Bot's behalf, and this branch makes it do exactly that — so somebody upgrading off those notes got the opposite of what they were told. The Unreleased section now names the reversal outright, and the 0.0.5 paragraph carries a pointer forward rather than standing uncorrected. * Give the queue's wake-up listener back on the way out, like its siblings `startWorkOfferedListener` holds a Postgres connection for the life of the process and returns a `stop()` for giving it back, and the return value was dropped on the floor. The shutdown beside it stops the two listeners either side and says why in its own comment: "so a watch-mode restart does not leave two behind on every reload". This one was the third, and it was the one not stopped, so every reload of `bun run dev` on a deployment with handing work between Bots switched on left a connection behind. Held at module scope because the listener is started inside the gate that may never run, and the shutdown is at the top level: undefined there means a deployment with the capability off, which never started one. Checked rather than assumed: `pg_stat_activity` goes from one row to two when the listener starts and back to one after `stop()`. * Say in the README that this is a template to clone, not a product The framing existed only in a code comment ("OpenBot exists to be forked"), which is the wrong place for the first thing somebody needs to know: that there is nothing to sign up for, nothing to install as a dependency, and that the example tenant package is a worked example to replace rather than a default to keep. Read without it, "Quick start" looks like the way you use OpenBot rather than the way you try the starting point before making it yours. Placed above the alpha and laptop callouts because it frames both. * Add a setup prompt for somebody doing this with an AI, and link it from the README Reported as hard to set up even with an assistant helping, and the reason is legible in `.env.example`: it ships ten empty keys, and only three of them are a person's to fill. The start script defaults two and generates the third, so an assistant reading the file alone will walk somebody through seven values that fill themselves. `prompt.txt` says which three, what each start-up refusal means in the words the server actually prints, and what an assistant must not do on somebody's behalf: run the browser sign-in, echo their keys back, or change the Intelligence URLs, which are correct as shipped and not self-serve to replace. Every claim in it was checked against this repository rather than written from memory: the pinned Bun version, the port defaults, the five refusal messages quoted verbatim, and that TENANT_PACKAGE_DIR is relative to `server/` rather than the root, which is why the default carries a `../` that is easy to drop. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
|
Verified and want this (the run-lock leak fix is real and correct). Holding only for a rebase: #290 ("Relay handoff answers home") landed after this branched and reworked the exact lock/history region in |
* Gate first sign-in behind a per-user onboarding wizard Two columns on users say where somebody is in first-run onboarding and when they finished; /api/me carries the status and one POST moves it. The app redirects an unfinished user to /onboarding — an animated three-step wizard — and an address the build does not know now goes home through the same gate instead of a bare 404. * Say work is offered aloud, so a sweep can start now instead of at its next poll The handoff queue was swept every two seconds, and a person is waiting through every hop, so each leg of a handoff cost up to a full interval doing nothing. Offering work now fires pg_notify with the kind as payload, inside the offering transaction where there is one so it fires on commit and never before, and any replica can listen on a dedicated connection and kick its sweep immediately. A notification is a latency optimisation, never a delivery mechanism: one lost in transit costs up to one poll interval, not the work, and the queue's tables stay the only truth. * Let a channel tell its members a turn is running in it A transient busy flag on the channel activity event, announced and never written: busy is a moment, not a fact about the channel, and a missed signal costs at most a stuck-looking dot until the next real event, never data. Two ways in. The server signals by thread (signalBusy) for the runs it can see — the runtime's lock acquire and release now carry an onRunBusy seam, so every run the platform processes lights its channel, including one whose tab has navigated away — and a scratch thread maps to no channel and signals nowhere, which is the point of a scratch thread. The browser signals by channel (POST /:channelId/busy) for the one thing the server cannot see, a person's own turn beginning, with a membership check so belonging to a channel is not something an outsider can probe for. * Relay a handoff answer back into the conversation that asked A forward hop used to answer in the addressed Bot's own channel with the person: two conversations for one question, and the answer landed somewhere they never asked anything. Now the hop runs in a scratch thread of the addressed Bot's own — minted per hop, never mapped to a channel, never shown — and what it said comes home through a second queued hop that has the asking Bot relay the answer, attributed, in the conversation the person is watching. The delivery gathers the Bot's words from the stream as it goes past, because the runner publishes the turn to the platform and the events are the one chance to hear it. The relay rides the same durable queue as the turn that produced it, so a pod dying between the two loses the relay to a retry rather than for ever; the answerIn marker that stops a failure notice recursing stops a relay relaying. Answers are clipped at 12k characters so a Bot that comes back with a book cannot swamp the relaying run's prompt, and a backwards hop reads only the tail of the asking conversation so relaying never grows slower with the channel. Wired in index.ts: the scratch thread replaces the direct-channel answerIn, the roster announcement resolves thread to channel and happens only when the turn said something, the asking channel is lit while a forward hop runs, and the queue's new offered-work notification kicks the sweep so a person is not waiting out a poll interval per leg. * Show a channel working on the roster while a turn runs in it Three bouncing dots badged on the channel's avatar, driven by the busy flag on the activity socket. Socket-only and transient on the summary type: the roster query never returns it, so it is undefined until a busy event arrives and drops whenever the roster refetches — the acceptable failure for a hint about a moment. The event patcher flips only the busy field: the spread that serves ordinary activity would carry the event's null message onto the row and wipe the preview, and busy is not activity, so the row does not re-sort either. The channel reports its own turns over the new busy endpoint, fire-and-forget, and deliberately does not clear on unmount: a turn keeps running server-side after the person leaves the channel, and the server clears it when the run's lock is released. * Keep an open channel current with turns nobody here streamed A relayed handoff answer runs on the server and lands in the thread with no browser attached; the transcript restored history once, on mount, and would show the new turn only after leaving and coming back. The chat now watches the roster's own channel-list cache — the sidebar updating and the transcript refreshing are one signal and cannot drift apart — and when this channel's lastMessageAt advances to a moment a Bot authored, the durable history is read again and messages whose ids the transcript has never seen are appended. Appended by id, not compared by length, because the stored read keeps only what the platform can parse and can be shorter than the screen while still holding the news. Retried briefly, since the roster is patched when the turn is on record with the runner and the platform's read can be a beat behind. The chat also reports its own turns to the busy endpoint, keyed on whether a turn is in flight, so the roster's working dots cover the one run the server cannot see begin. * Start a new chat from anywhere with Shift+N A small hotkey registry, one place on purpose: the binding a listener matches against and the combo the settings page shows are the same record, so the list under Preferences is what the keys actually do rather than what somebody remembered they did — each key drawn as its own keycap, symbols on a Mac and names elsewhere. Matching is exact rather than at-least, so Shift+N does not fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain Shift+letter combo is left alone while the focus is anywhere editable. Bound in _authed rather than _app, so a person on settings or admin can start a chat without first clicking back into the app frame. The new-channel composer's recipient picker takes focus on arrival, so Shift+N then typing a name is one motion. * Format the onboarding route The wizard shipped unformatted; biome's own output, no hand edits. * Say whether this deployment can make a built-in coworker A coworker created with no endpoint runs on the deployment's managed Bot, and a deployment without one refuses the create — after somebody has already filled in the form. GET /api/agents/capabilities states it up front, static per process because it is configuration rather than data, and registered above the parameterised route so "capabilities" can never be read as an agent id. The browser caches it forever for the same reason, through a new agentCapabilitiesQueryOptions. * Create a coworker in a multi-step questionnaire dialog Three steps rather than one form: who it is (name, title, role), who can see it, and where it runs — the fork where a built-in coworker needs nothing more and a managed one reveals the endpoint and key fields. Built on the shadcn questionnaire primitives for the choice cards and fieldset semantics, with navigation driven by this dialog's own Continue/Back: the primitive's submit path refuses items it does not consider answered and cannot see these fields. Panes slide like onboarding — popLayout with a measured, height-following frame whose 'relative' is what keeps the exiting pane clipped inside it. Each step validates its slice of the shared form schema on Continue, and the Built-in card is shown but disabled, with the reason, on a deployment whose capabilities say it cannot back one. The visibility cards in AgentFields move to the same radio-card idiom, on a radio-group primitive added for it. * Show a coworker in a sectioned dialog with a sidebar of its own The profile carries four distinct concerns — who it is, where it runs, what it may hand work to, and what can be done to it — and the side panel stacked them into one column that buried the later ones. Each is a section now, behind a dialog-internal sidebar headed by the coworker's avatar and name. General leads with name, title, role and visibility as items that edit in place: Edit opens that field and that field alone, validated against the same limits the server enforces, and visibility writes on pick because two named choices leave no draft worth holding. Below them, the shortcuts — start channel, duplicate, delete — each as an item with its button. Deleting confirms in a dialog stacked over this one, with the name in the question and a backdrop of its own; DialogContent grows overlayClassName for it, which also forces the backdrop to exist, because Base UI skips backdrops on nested dialogs and a stacked dialog is precisely the caller asking for one. * Open coworkers in dialogs from the agents screen The roster keeps both states in the URL — ?new opens the multi-step create dialog, ?agent=<id> the coworker's own — so Back still closes them and a link still lands on them. The DetailPanel goes, and with it the old NewAgent pane it slid in; the channel screen's use of the side-panel profile is untouched. The card grids also stop stretching their tracks: auto-fill over the card's own width, so the gutter is the gutter rather than whatever 1fr left over. * Sort two files' imports the way biome's assist asks * Read the asking conversation before taking the run's lock The history read is the one call in a delivery that throws on a platform error, and it sat after the lock was acquired but before the try whose finally gives the lock back: a 500 from the platform leaked the lock for its full TTL. On a forward hop that lock is a scratch thread and the leak costs nothing; on a relay it is the asking conversation itself, so the person could not type for two minutes, the retry a minute later collided with the hop's own leftover hold and spent an attempt on it, and a relay that ran out of attempts vanished without a notice. The read is of the conversation that asked, which the lock — taken on the conversation being answered in — never protected. Hoisted above the acquire it fails before anything is held, and the happy path holds the lock for less of the turn too. Raised by kevin9327 on #290, who had the same reordering up as #305 against main; a test now pins the ordering. * Refetch the roster as well as the user when onboarding completes Finishing the wizard navigates straight into the app, and the channel list — cached from before the wizard, or from a fetch that ran while it held the screen — could greet the person with an empty sidebar their reload then fixed. Seen once on a real deployment during review of #290 and not cleanly reproduced, so this is a hardening of the seam rather than a confirmed fix: both invalidations use refetchType "all" for the same reason, nothing is observing these queries while the wizard is up. * Say which wizard agents are examples, and unstick its two classes Two review findings on the wizard's roster step. The invented agents that top up a sparse deployment rendered identically to real ones, so "Support Agent" read as a Bot the deployment has; they are dimmed and labelled Example now. And max-w-lg had run together with overflow-hidden into a class that applied neither, which is why the roster cards drew wider than every other step. Also stops the onboarding store's comment promising that step is where the wizard resumes: it is stored and served but nothing resumes from it yet, and the comment now says so instead of describing a behaviour that does not exist. * Say in the changelog what changed, including reversing 0.0.5's relay stance Five user-visible changes were on this branch with no entry: the relay, the working indicator, the transcript catch-up, the onboarding wizard, and Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on the addressed Bot's behalf, and this branch makes it do exactly that — so somebody upgrading off those notes got the opposite of what they were told. The Unreleased section now names the reversal outright, and the 0.0.5 paragraph carries a pointer forward rather than standing uncorrected. * Say whether a coworker runs on the deployment's own Bot, and whether it may hand work on The agent DTO gains builtIn, computed by comparing the stored endpoint against the managed endpoint the server was configured with, and the handoff answer gains grantable, asked of the plugin store's own notion of where an agent runs. Both exist so screens can stop offering controls the server would only refuse: a built-in coworker was being nagged for a callback token it will never need, and a remote coworker was offered handoff switches that bounced. The endpoint comparison guards on typeof string because two undefineds compare equal, which quietly made every endpoint-less stub built-in. * Show routines per coworker, as rows that wear their state The routine DTO now names its agent, so the list can be scoped to one coworker's dialog while the Routines page keeps showing them all — one owner-scoped query either way, the scope a filter rather than a second endpoint. The row itself is rebuilt as a muted item whose footer is a set of chips: the channel it posts to (a link, since it is a place), the last run with a colored dot carrying the tone, and either the next run, Paused when switched off, or a pulsing Due when the stamp is already in the past — which used to render as the nonsense 'Next 5 hours ago'. Empty states go through the shared Empty component. * Rebuild the handoff panel on items, and say once when granting is impossible Each candidate Bot is a muted item with its avatar and a switch, under a header that answers 'how many of them' at a glance. A coworker that cannot be granted the handoff tool — it runs as its own agent, outside this deployment's loop — gets one explanation item instead of a column of switches that can only bounce off the server's refusal; its stale grants stay visible so they can still be revoked. * Grow the coworker dialog: access, routines, a truthful connection tab, and a phone strip The dialog gains an Access section (which connectors and skills this coworker has been granted, grouped from its plugin refs), a Routines section (the scoped list, replacing the global sidebar entry — the /routines route still answers direct links), and a Connection tab that tells a built-in coworker the truth: it runs on this deployment's own Bot, nothing to connect and nothing to authenticate, instead of nagging for a callback token. Below the md breakpoint the sidebar gave way to nothing and most sections were unreachable; a scrollable strip of section buttons now takes its place, with the coworker's name above it and room left for the close button. * Slim the channel's coworker panel to a card that opens the dialog The panel beside a conversation answers 'who am I talking to' — avatar, name, role, two buttons: start a new channel with this coworker, or open the management dialog. It used to duplicate the dialog's whole surface (edit form, tokens, grants, delete), which was two places to maintain and a sidebar that scrolled past the conversation it sat beside. agent-fields and the radio-group primitive go with it: the edit form was their last caller, and the create wizard draws its own choices. * Say in the changelog what the dialogs change for the person running OpenBot * Let the handoff explanations finish their sentence `ItemDescription` clamps to two lines, which is right for a roster row whose description is a subtitle and wrong for an item whose whole job is to explain. Both explanations here run to three, and in each case the line that gets cut is the useful one: "It can still be asked by Bots that can" is exactly what a person reading "this coworker cannot hand work on" needs next, and it was invisible on screen while sitting in the DOM. Seen on a deployment rather than in the file: the text reads complete in the accessibility tree and truncated with an ellipsis in the browser. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
|
Closing this as already landed, but you found a real bug and got there independently. The same fix went in with #290 ( It is on Nothing wrong with your version. It was a collision, not a rejection. Thanks for chasing it down, and please do send the next one. |
What this changes
server/src/agents/handoff-delivery.tstakes the run lock on the conversation the answer lands in,and then reads the history of the conversation that asked — a different thread, which the lock
never protected:
:212const held = await lock.acquire({ threadId: where.threadId, … }):248await history({ threadId: work.threadId, actorId: work.actorId }):265try { … } finally { clearInterval(heartbeat); await lock.release(…) }The read at
:248is the oneawaitin the delivery that sits between the acquire and thefinallythat gives the lock back.
historyOrEmpty(server/src/copilot.ts:953-963) answers a missing thread with nothing and rethrowseverything else, deliberately — "A 500 from the platform means an outage or a bad key". So one 500,
one expired key, one dropped connection, and the delivery throws with the lock still held:
(
handoff-runner.ts:393-399), which is correct.where.threadId— the addressed Bot's own conversation with that person — is neverreleased and never renewed (the heartbeat is only armed at
:261, after the read). It sits untilthe platform's TTL,
THREAD_LOCK_TTL_SECONDS = 120(copilot.ts:993). For those two minutes theperson cannot start a run in that conversation.
nullfrom
acquire, throws "is busy with another run" and spends one of its five attempts on it.The module already argues this case for the other direction. The
finallyat:348-359says:That is exactly what a failed history read produces — it just happens one line above where the
finallystarts.The fix
Hoist the read above the acquire. It is a read of another conversation, so the lock was doing nothing
for it, and moving it earlier also shortens the window the lock is held on the happy path. Nothing
else moves: the ask is still appended with the platform's own run id, which is only known after the
lock.
Where it runs
platform's, shared by every replica; a replica that leaked one made the conversation unusable
from all of them for the TTL, and the hop's own retry — which any replica may claim — walked
into it. After this, a failed read leaves nothing behind for another replica to trip over.
NXthrough the platform, and isstill the thing that serialises runs in a conversation. What changed is that it is no longer
held across an operation that can throw outside its
finally.Boundary and audit
retried, and
agent.handoff_retried/agent.handoff_failedare written by the runner exactlyas before. This changes only what is left behind on the way out.
Changelog
CHANGELOG.md, underUnreleased.Proof
server/tests/agent-handoff-delivery.test.tsgains ahistoryErroroption to the existing stubharness and one case: a history read that throws, asserting the whole lock sequence is empty. It is
red on
mainand green here.Against
main'shandoff-delivery.tswith the new test in place:With the change:
The sequence is asserted rather than the absence of a release, because a delivery that acquired and
then released would also be correct and the assertion should say which of the two happened.