Skip to content

A refusal the operator could act on arrives as a transport failure, and the sender is told nothing #773

Description

@fujibee

Title: A refusal the operator could act on arrives as a transport failure, and the sender is told nothing


A remote server may refuse a write with a status that means "the caller must do something" rather than "try again later"402 payment_required is the case that exists today, and any server implementation may return it. The engine does not distinguish that from a transport failure, records no reason, and send has already returned success to the agent. From every side it looks like the message went out.

What was measured

scripts/internal/remote-sync.mjs, on integration/remote @ d2e5f43.

402 appears nowhere.

grep -nE '402|payment_required' scripts/internal/remote-sync.mjs
  -> 0 matches

Not retrying it is right, and is the only thing about it that is right.

:1598  export function isRetryable(error) {
:1599    if (error?.retryable === true) return true;
:1600    return [408, 429, 500, 502, 503, 504].includes(error?.status);

A 402 raises HTTP 402 payment_required out of request() (:1051-1056) and lands where HTTP 500 internal and HTTP 403 forbidden land. The one refusal a person can act on is shaped exactly like the ones they cannot.

Nothing records why a cycle failed. #756/#760 added a cycle stamp, so status can say whether anything has synced. That answers "is it working?". Nothing answers "why not" — there is no last_error equivalent in the engine.

And the sender already saw success. send commits to the local store and returns; the push happens later. A refusal there reaches nobody.

agent      send            -> local commit -> SUCCESS
engine     push            -> refused      -> raised, not recorded
person     status          -> "nothing has synced"
                           -> no reason

That is worse than looking stopped. It looks sent.

What "fixed" looks like

1. Classify a refusal the caller can act on, separately from a transport failure.

Not retryable — the answer will not change by asking again. But not "an error" in the transport sense either: the server has made a decision and stated it. The engine needs a third outcome beside succeeded and failed, will retry: refused, and here is what the server said.

The distinction is about the class of status, not about one number. A server may refuse for reasons this protocol never enumerates.

2. Keep what the server said, verbatim, and make it readable.

The refusal carries a status and a code. Store both, and the time. Do not translate them into a sentence about why — the engine cannot know why, and a sentence it invents will be wrong for some server.

This is the same shape as the cycle stamp #760 added: one more fact beside it, not a new mechanism.

3. The agent must be able to answer when asked.

Someone asks their agent "why isn't this syncing?" and the agent should be able to say: the last cycle was refused by the server, with this status and this code, at this time. Today the agent has nothing to read.

So the recorded fact has to sit somewhere the agent's own surfaces reach — the same place it learns anything else about the engine — not only in a command a human types.

4. Assume nothing about which server is on the other end.

agmsg talks to a remote. It may be self-hosted, someone else's, or a service. The engine must not carry knowledge of any particular one: no product name, no URL, no advice about subscribing, no mapping from a status to a business meaning.

What it may do is repeat what the server said and say where the operator of that server would be reached — which the config already knows, because the endpoint is in it. Interpretation belongs to whoever runs the server, not to the client.

402 is the case that exists today. The fix should be right for a self-hosted server that returns it for its own reasons.

5. Decide what send does — this is a design question, not an implementation detail.

  • Keep accepting sends and let them queue — the current behaviour, and defensible: local-first means the local store is the record, and nothing is lost. But then the person must be able to find out, or they accumulate silently.
  • Refuse the send — honest at the moment of the action, and wrong for a local-first tool: the message belongs locally whether or not any server will take it.

The first is probably right, which makes 2 and 3 the whole fix. A queue nobody can ask about is the defect. A queue with a readable reason is a feature.

Not in scope

Why a particular server refused, and what a person should do about it. That belongs to whoever operates that server.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions