Agent-First PSQL v0.11.0: A Cancel Is a Request

by Agent-First Kit Contributors

Cancelling a query took its terminal event slot before the CancelRequest was even sent, so a write that had already committed came back as 'cancelled' — the worst answer this program can give. That, a COMMIT sent down a brand-new connection after the backend died, and inspection panels rebuilt around objects instead of the snapshot query's row shape.

Two of the fixes here are about the same thing from opposite ends: a database telling you an operation did not happen when it did. An agent that believes it redoes the work, or tells a person nothing happened. Both answers are worse than an error.

A cancel is a request, not an outcome

Cancelling a query claimed the query’s terminal event slot before the CancelRequest had even been sent, emitted cancelled, and dropped whatever the worker eventually reported.

The protocol says nearly the opposite. A CancelRequest gets no reply. It can arrive after the backend has already finished. The client has to keep waiting on the original query to learn what actually happened — which means “I asked for a cancel” and “the query was cancelled” are different facts, and only the second one is an outcome.

So a committed write could be reported as cancelled. That is the worst answer this program can give: the agent either does the work twice or reports to a person that nothing happened, and the database disagrees with both.

Now the two cases are separated by whether anything reached the database at all:

The first attempt at this mapped SQLSTATE 57014 straight to cancelled, and the gate caught it: PostgreSQL sends the same code when statement_timeout expires, and the only thing separating the two is message text callers are explicitly told not to read. It settles on what this side already knows — whether a cancel was requested for that query at all.

A lost COMMIT is not a retry

tx_commit and tx_rollback called ensure_connected.

Read that with a dead backend in mind. The connection carrying the open transaction is gone; ensure_connected opens a new one; COMMIT goes down it. PostgreSQL answers a COMMIT outside a transaction block with a warning and a success tag — so the caller was told a transaction committed when the transaction, and everything in it, had been rolled back by the disconnect.

Neither call reconnects now. A transaction that lost its connection reports that, which is a thing an agent can act on.

Panels shaped like objects, not like the query

ui table, ui schema and ui indexes handed the template the schema-snapshot query’s own row shape: eight kinds of object unioned into one set of generic columns, most of them null on any given row, with nullable, primary key, unique and owner packed into a payload object a person had to read as JSON.

That is the right shape for an agent and the wrong one for the reader these panels exist for.

The translation happens in a document builder now rather than in a template, because the interesting parts are judgements about what is true: grouping rows by relation, dropping the constraint rows that only restate a column’s own not null, and reading estimated_rows: -1 as “never analyzed” rather than as a count. A template still decides whether a column list renders as a table or a list; it never has to know that -1 is not a number of rows.

One shape per panel: a table opens on its object, a schema is a scannable list of collapsed summaries, indexes group under their table. document.table still carries the untouched result set behind a details block, so nothing that was summarized is thereby hidden. templates/relation.html.j2 joins the contract, shared by the table and schema pages and includable by an override that ships no copy of it. ui_api_version is 2.

Where a panel can reach, and where it cannot

The five ui panels take --mode window|session now, resolved through AFUI’s own delivery plan — explicit --mode first, then AFUI_DELIVERY, then window. Before this they built a session and opened a window directly, so a machine with no display could not open one at all.

link is refused, and refused before the panel does any work or the readiness event goes out. Publishing a database panel over link binds every interface and advertises the result on the network with no tunnel — a wider, unencrypted exit for exactly the rows a locked profile already withholds.

That reasoning also replaced the locked-profile refusal’s old one. It used to say that opening a window spawns a browser and writes a profile directory, which is true and covers only window delivery — a published session would have been waved through. Judging by where the data behind a panel can leave the process holds for every delivery a panel could ever gain, without growing a case per delivery.

Smaller things

In a framed session the monitor hides its own header and sends up the live line, since the frame already says whose program this is and what the session is on. A panel that is waiting for a decision keeps its head wherever it is shown, because a person is being asked to answer for what that page says.

Apple’s container joins the transports — --container-apple-name, --container-apple-user, --container-apple-runtime — and the local gates run on it.

Trusting a frontend in a test names the config directory instead of pointing a process-wide variable at it. Cargo runs test functions on several threads, so two tests trusting at once wrote into each other’s store, and whichever lost served the built-in page — under an assertion that said “my override did not serve”, which points nowhere near the cause. Three parallel runs of the old suite failed 2, 0 and 1 times.

And the pipe test stopped counting a cancel acknowledgement as an outcome, which made it pass or fail on whether the cancel happened to land while the query was still queued. It asserts the acknowledgement arrives at most once instead.

Breaking changes

Getting it

$ brew install agentfirstkit/tap/afpsql
$ cargo install agent-first-psql