fix(execute): reconcile writes that report completed without a transaction hash - #99
Conversation
…ction hash A successful POST /api/execute/contract-call can return 202 with status "completed" and no transactionHash. The hash only appears on the status endpoint. Both transfer and contract-call treated any "completed" as terminal, so --wait printed a success and returned while the caller still had no transaction to verify, which is the one outcome that flag exists to prevent. --wait now reconciles against the status endpoint when a completed write carries no hash, and returns immediately when it does, so the common case costs no extra request. Also honours X-Poll-Interval-Hint. fetchExecStatus previously discarded the response and both poll loops ran on a hardcoded two second ticker, so the documented pacing header had no effect. The hint is now used, a hint of 0 is treated as terminal, and an absent or unparseable hint falls back to the previous two second default. Polling is still bounded by the caller's --timeout. unconfirmed is unchanged: it is absent from execTerminalStatuses and so remains non-terminal, which is already correct. Tests: six cases for the reconciliation predicate, six for hint parsing, and three behavioural tests driving the commands against an httptest server. All three behavioural tests were confirmed to fail against the previous behaviour, one of them catching the old 2s timer at 1.99s.
|
Heads-up on a file overlap, so this doesn't collide with work already in flight. #95 ( I'm happy to rebase on top of #95 once it lands, or to split the I also want to be explicit that this PR deliberately does not touch idempotency-key handling or execution recovery, since #97 and related work already cover that ground. |
suisuss
left a comment
There was a problem hiding this comment.
This is your first PR here - ISSUES.md requires an accepted issue before any behavior-changing PR, referenced in the title as fix: #<n> <description> per CONTRIBUTING.md.
What this changes
cmd/execute/transfer.go:112 and cmd/execute/contract_call.go:127 gate the "is this write terminal" check behind a new writeNeedsReconciliation check (transfer.go:215-217): a broadcast response reporting status: "completed" with a nil or empty transactionHash no longer short-circuits --wait; it falls through to poll /api/execute/<id>/status instead. fetchExecStatus (transfer.go:182-206) now also reads the X-Poll-Interval-Hint response header (status.go:26-39, new nextPollDelay) and returns the delay to sleep plus a "server says terminal" bool alongside the status. pollExecStatus (transfer.go:146-178) and watchExecStatus (status.go:142-166) drop their time.NewTicker(2s) + select{default: sleep(50ms)} busy-loop and instead fetch immediately, then sleep for whatever the server asked for (falling back to 2s, capped to the remaining --timeout in pollExecStatus; uncapped in watchExecStatus). Two new test files cover the reconciliation predicate, hint parsing, and end-to-end behavior against httptest servers.
Does it match the description
Scope creep - the write-up accurately describes both changes it makes: the reconciliation fix matches the diff exactly, including the live-API repro, and the X-Poll-Interval-Hint fix is also accurately described, including its effect on test-suite runtime. Nothing is undersold or oversold about what each piece does. But the two are independent: writeNeedsReconciliation gating the broadcast-response check works correctly with the old fixed-2s ticker still in place, and the poll-hint honoring works correctly with no reconciliation gate at all - reverting either half in isolation leaves the other fully correct and deployable. The body's own "Scope" section calls the change "deliberately narrow" in the same breath as bundling this second, unrelated fix. ISSUES.md is explicit on this exact situation: "confirm it is one issue: if any part could be fixed and shipped while another stays broken, those are separate issues." The contributor's comment already volunteers to split the hint change out.
Blocking
- No issue reference anywhere in the title (
fix(execute): reconcile writes that report completed without a transaction hash), and ISSUES.md requires an accepted issue before any PR that changes command behavior - this qualifies twice over, under both "Command behaviour, output format, or exit codes" and "Anything that changes what an existing flag does" (--wait's and--watch's polling/termination semantics both change).check-issue-linkis failing on the head commit right now, confirming this mechanically. Close by filing an issue (or, given the scope-creep finding below, two issues - one for the reconciliation bug, one for the poll-interval-hint behavior), getting eachaccepted, then retitlingfix: #<n> <description>per CONTRIBUTING.md, splitting into two PRs if the maintainers agree with the split. cmd/execute/transfer.go:155(pollExecStatus),cmd/execute/status.go:155(watchExecStatus),cmd/execute/status.go:88(plainkh ex status) - the reconciliation check added at the broadcast response is not applied to the status responses these three consume. All three treat anystatus: "completed"fromGET /api/execute/<id>/statusas final viaexecTerminalStatuses["completed"], with no equivalent ofwriteNeedsReconciliationguarding it. Concrete scenario: the status endpoint itself returns{"executionId":"e1","status":"completed"}with notransactionHashkey - the same response shape the PR's own bug report shows the live API producing on the broadcast endpoint, and nothing in the diff or the new tests establishes the status endpoint is immune to the same condition.pollExecStatusreturns viaprintExecStatusResult(transfer.go:219-231), which omits theTX Hashrow when the pointer is nil/empty and returnsnil;watchExecStatus/kh ex statusdo the same viarenderExecStatus(status.go:104-140).kh ex transfer --wait(orkh ex st --watch) then exits 0 reporting "completed" with no hash and no error - the exact outcomewriteNeedsReconciliation's own comment names as "the one outcome the flag exists to prevent," now one hop downstream of the fix. Apply the same completed-without-hash check everywhere a status response is treated as terminal, not only at the broadcast response.
Mechanical - actionable as-is
cmd/execute/status.go:26-39-nextPollDelayaccepts any non-negative integer with no upper bound.pollExecStatusclamps the sleep to the remaining--timeout, butwatchExecStatushas no deadline at all, so a large-but-parseable hint (a units mismatch, e.g. milliseconds sent where seconds are expected, or a server bug) leaveskh ex st --watchsleeping for that entire duration with nothing capping it short of Ctrl-C. Clamp to a small sane ceiling, e.g.min(secs, 30).cmd/execute/transfer.go:182-fetchExecStatusnow returns four values (*ExecStatusResponse, time.Duration, bool, error); atstatus.go:88two of the four are already discarded with_, _. A small result struct would read more clearly at each of the three call sites. Not required.
With the team
Two things worth a maintainer decision rather than a mechanical fix. First, whether to require the contributor's own offered split (reconciliation fix and poll-interval-hint honoring as two issues/PRs) given ISSUES.md's scope rule matches this case directly. Second, how to sequence this against #95 (feat(execute): require chain-verified receipts in status, open, also rewrites cmd/execute/status.go, opened before this PR) - the contributor already flagged the textual collision and offered to rebase once #95 lands; worth confirming which lands first before either gets a deeper review pass.
Verdict
Changes requested: the issue gate is unmet (and CI already says so), the reconciliation fix does not cover the polling paths that reach the same status field, and a real independent seam should be split before merge.
|
Answering the two things you offered, which I left hanging in the review. Split it. The reconciliation guard and the On the overlap you flagged: you are right that #95 touches The remaining code ask from the review still stands: the completed-without-hash guard belongs on the paths that consume a status response, not only the broadcast response - |
A completed execution can legitimately carry no transaction hash. Any action that submits nothing onchain - a read-only contract call, a non-transaction plugin step - finishes exactly that way, so the three paths that consume a status response now report the condition in their output and leave the exit code alone. Failing on it is opt-in behaviour and belongs behind a flag. Clamp X-Poll-Interval-Hint to 30 seconds. watchExecStatus has no deadline, so an oversized or hostile hint parked the loop for as long as the server asked. A hint of zero still means terminal and a negative or unparseable hint still falls back to the default, so neither can spin the loop. Treat unconfirmed as terminal. The server hands it back once a transaction was broadcast but no receipt was observed, and nothing moves it until the reconciler runs on its own schedule, so --wait and --watch previously polled on until the caller's timeout. They now stop there and exit zero, because a non-zero exit invites a retry, and retrying a broadcast can double-spend.
|
I have settled the question this PR turned on, and it went the other way from the premise you built on, so I pushed the change rather than asking you to rework it. A Your poll-hint work stands as filed. The hint is now clamped to 30 seconds -
Two things to flag. First, #95 also adds Seven tests added. I checked none of them are vacuous by reverting each fix in turn and confirming the matching test fails. |
Reconciles this branch's poll-loop rework with the terminal-status and receipt-verification work that landed on main, keeping both. Poll loops - this branch's shape wins. pollExecStatus and watchExecStatus keep the hint-driven sleep and drop main's time.NewTicker and its select/default busy-wait. fetchExecStatus keeps the four-value signature (status, delay, serverSaysTerminal, err), the 30s maxPollIntervalSecs clamp survives, and pollExecStatus still clamps the sleep to the caller's deadline. Both terminal checks keep the || serverSaysTerminal arm. Terminal-status handling - main's shape wins. Every terminal branch (the immediate write path in transfer.go and contract_call.go, and pollExecStatus) now runs the same order: terminalExecError first, then print the result, then printUnconfirmedNotice when the status is unconfirmed, then return nil. That keeps the fix for --wait exiting zero on a fast failure. transfer.go keeps main's declaration block - the execStatusUnconfirmed const, the map built from it, printUnconfirmedNotice and terminalExecError - which is a superset of this branch's literal map, alongside this branch's noTransactionNote and completedWithoutTransaction. Reconciling fetch - this branch's guard survives on both write paths, so a write reporting completed with no transaction hash still falls through to one status fetch instead of returning immediately. watchExecStatus keeps main's signature and its renderExecStatusChecked call inside this branch's loop body; the --require-verified plumbing, verifyExecReceipts and renderExecStatusChecked are unchanged. renderExecStatus is the union: main's receipt rows, unconfirmed notice and failed error, plus this branch's Transaction/none submitted row placed with the other transaction rows. Tests from both sides are kept. Both sides had added a TestTransferCmd_WaitStopsOnUnconfirmed, testing the same behaviour from different angles; this branch's copy in reconcile_test.go is renamed TestTransferCmd_WaitStopsOnUnconfirmedWithPollHint, which is the poll-hint path it actually exercises.
|
Merged
Your loop shape won everywhere it mattered. The hint-driven delay, the 30-second clamp and One thing to know about your branch: you and #95 had both written a test called The resolution was mutation-checked rather than eyeballed: removing |
Keep approved PR KeeperHub#97 idempotency, watch-404, and receipt outcomes while taking main's X-Poll-Interval-Hint loop and completed-without-hash reconciliation.
The bug
A successful
POST /api/execute/contract-callcan return202withstatus: "completed"and notransactionHash. The hash only appears onGET /api/execute/{id}/status.Both
kh ex transferandkh ex contract-calltreat anycompletedas terminal:So
--waitprints a success and returns while the caller still has no transaction to verify. That is the one outcome the flag exists to prevent.This is not hypothetical. Here is a real response from the live API:
The transaction was real and did land:
0xb4098917…d452dc. The hash was only obtainable from the status endpoint.The fix
--waitnow reconciles against the status endpoint when a completed write carries no hash, and returns immediately when it does, so the common case costs no extra request.Also:
X-Poll-Interval-Hinthad no effectfetchExecStatusdiscarded the*http.Response, so the header could not be read, and both poll loops ran on a hardcodedtime.NewTicker(2 * time.Second). The documented pacing header was silently ignored.The hint is now honoured, a hint of
0is treated as terminal, and an absent or unparseable hint falls back to the previous 2s default. Polling remains bounded by--timeout, and the wait is clamped so it never overshoots the deadline.A side effect: the existing
cmd/executesuite went from 8.5s to 2.5s, because tests no longer wait on a fixed timer.Not changed
unconfirmedis already handled correctly. It is absent fromexecTerminalStatuses, so it is treated as non-terminal and polling continues. No change needed.Tests
failedshapeshttptestI checked that each behavioural test fails against the previous behaviour rather than passing regardless:
go build ./...,go vet ./...and the fullgo test ./...are green on Go 1.26.Scope
Deliberately narrow: no new flags, no request-shape changes, no
networktochainIdmigration. Those are worth doing but are separate conversations, and I did not want a correctness fix to depend on agreeing about them.Found while building keeperhub-flightcheck, an onboarding execution-conformance check, for the Agents Onchain hackathon. Happy to adjust anything here.