Repository navigation
Report a write tool's outcome as unknown when GitHub's answer is lost or is a 5xx - #17
Conversation
A fake GitHub keeps a request and then closes the socket without answering, as a connection that drops after GitHub stored a write does: - a comment and an issue GitHub received should read as unknown, not as failed, and the comment's text should say a rollback won't undo it; - GitHub's own 5xx on a write should read as unknown too; - a read lost the same way, and a write GitHub refused with a 4xx, still read as failed; - a lost GraphQL mutation may have taken effect, and a lost GraphQL query didn't; - a write tool's own lookup whose answer was lost (upsert_sticky_comment's listComments) is a failed read, and nothing is written; - a token mint whose answer was lost is a failure of the tool's request, which never went out, for a read and for a comment alike.
A write whose request went out but whose answer never came back, or that
GitHub answered with a 5xx, may have taken effect. Until now the tools
said it failed, and an agent reading that may write again.
- github-adapter: normalizeFailure also keeps whether the failed request
only read (a GET or HEAD, or a GraphQL document that begins with query
or {) and whether GitHub answered at all. GitHubApiError carries them as
possiblyDone (status 500 or more, and not a read) and answered. A
failure whose request can't be judged counts as a possible write. A
failure to mint a token or find the installation comes before the
request is sent, so it is never possibly done.
- tool-support: describeFailure says, for such a failure, that whether it
took effect is unknown, and to look on GitHub before trying again.
- tools-comments: the comment tools, which threw on any failure, return
that text for this case as an error result, and add that rollback has no
record of the write if it took effect: it was never booked.
- server and tool-support: three comments that said an error result means
nothing happened now say only what holds.
- A changelog fragment.
channels/publish is unchanged: an error answer to a publish doesn't claim
the message wasn't posted, and agent-framework, for one, reads it as
delivery uncertain.
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit fe29c40 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This is a bug fix to how the existing write tools report failures. When GitHub's answer is lost or comes back as a 5xx, the tools now say the outcome is unknown instead of claiming the write failed, and the work stays in the adapter and tool-support layers that already own this.
5 reasons with sources
- It fixes behavior the project already has: wording of tool error results, and comment tools throwing where they should return an error result. The comment on runTool before the change said every GitHub error comes back as an error result the model can read, never as a thrown protocol error. On main, the comment tools threw a lost write as a JSON-RPC -32603 error. This change brings those tools back in line with that convention. (src/tool-support.ts (runTool doc comment, before the change))
- It supports the README's promise that comment rollback tracks the bot's writes, by telling the agent when a write may have landed without being booked. The README says github.comments is checkpointed and that rollback deletes the comments created after a checkpoint. A comment that GitHub stored but whose answer was lost would be missed by rollback. The new text says so, and adds no new rollback mechanism. (README.md (Feature sets with rollback))
- The read/write classification sits in the adapter's existing failure normalization, and the text the agent reads is built in the existing describeFailure. GitHubApiError gains two fields with defaults. No new module, dependency or public tool is added. (src/github-adapter.ts (normalizeFailure, GitHubApiError))
- Minor layering point: tool-support.ts now imports the concrete adapter module, which before this change only index.ts did. Before the change, tool-support read the failure's status by duck typing (statusOf), and only src/index.ts imported github-adapter.js. The separate review already lists this as a low-severity finding; it doesn't change whether the change belongs. (src/tool-support.ts, src/index.ts)
- The change is one coherent piece of work, and its changelog fragment follows the documented naming and format. The fragment lost-answer.fixed.md is a flat file made of bullets, with continuation lines indented two spaces, as the guide asks. The server.ts edits only touch comments. (changelog.d/README.md)
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. Uncertain comment writes are no longer logged anywhere.
- Medium severity, test coverage. Only
commentis tested for the new comment-tool error result. - Medium severity, test coverage. GraphQL read/write classification is tested only on two simple documents.
4 more findings, less severe
- Low severity, maintainability,
src/tool-support.ts:31. Tool layer now depends on the concrete adapter class. Before this change, the only module that importedgithub-adapter.tswassrc/index.ts. The tool modules talked to GitHub only through theGitHubApiinterface intypes.ts, andstatusOfreads an error's status by duck typing for the same reason.uncertainWriteusesinstanceof GitHubApiError, so it tiestool-support.ts, and through it every tool module, to octokit's adapter module. It also means any otherGitHubApiimplementation, such as a test fake, can never produce an 'unknown' outcome. A duck-typed check onpossiblyDone/answered, or moving the error type intotypes.ts, would keep the layering. - Low severity, test coverage,
src/github-adapter.ts:847-850. Installation-lookup failure path is not tested. The rebuilt auth failure is meant to cover both a failed token mint and a failed installation discovery (resolveInstallationId). The new test only loses the mint's answer. When the installation lookup fails inside the transport, the tool's write request was never sent, and no test checks that this case also comes out withoutpossiblyDone. Add a case with GITHUB_APP_INSTALLATION_ID unset and the/installationlookup lost. - Low severity, test coverage,
test/lost-answer.test.ts:141-149. No test that a 5xx answered on a read stays 'failed'. The read test only covers a lost answer (answeredfalse). Thefailure.status >= 500 && !failure.readbranch is never tested with a GET that GitHub answers with a 502/503. That is the common real-world 5xx, and it must not be reported as 'unknown'. A similar case forcreate_issueanswered with a 5xx, whose text should say 'GitHub answered createIssue with 5xx', is also missing. - Low severity, maintainability,
test/lost-answer.test.ts:21-30. Test leaves a temporary private-key directory behind.mkdtempSynccreates a directory under the OS temp dir and writes an RSA private key into it when the module loads. Theafterhook stops the fake servers but never removes this directory. Every test run leaves one more key file in /tmp. Remove it in theafterhook withrmSync(..., { recursive: true }).
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| async call(name, args, ctx) { | ||
| try { | ||
| return await callComment(name, args, ctx); | ||
| } catch (err) { | ||
| const uncertain = uncertainWrite(err); | ||
| if (!uncertain) throw err; | ||
| return refuse(`${field(name)}: ${uncertain} If it did, rollback has no record of it.`); | ||
| } |
There was a problem hiding this comment.
Medium severity. Uncertain comment writes are no longer logged anywhere.
Before this change, a GitHub error thrown by a comment tool reached the dispatcher in src/server.ts (lines 886-892), which logs request failed at error level with the tool name. Now commentsToolModule.call catches the possibly-done error and returns refuse(...) without logging. Every other tool module goes through runTool (tool-support.ts line 179), which logs tool call failed at warn level. So the one case where a write may have landed without being booked for rollback leaves no trace in the operator's logs. Log the uncertain write here, the way runTool does.
|
|
||
| test('a comment GitHub stored whose answer was lost reads as unknown, not as failed', { timeout: 10_000 }, async () => { | ||
| const { fake, ctx } = await rig({ [`POST ${R}/issues/7/comments`]: 'lost' }); | ||
|
|
||
| const result = await commentsToolModule.call('comment', { thread: 7, body: 'hello' }, ctx); | ||
|
|
||
| assert.ok(fake.received.some((r) => r.method === 'POST' && r.path === `${R}/issues/7/comments` && r.body.includes('hello')), 'GitHub got the comment'); | ||
| assert.equal(result.isError, true); | ||
| const said = text(result); | ||
| assert.match(said, /whether it took effect is unknown/); | ||
| assert.match(said, /rollback has no record of it/); | ||
| assert.doesNotMatch(said, /failed/); | ||
| }); | ||
|
|
||
| test("GitHub's own 5xx on a write reads as unknown too", { timeout: 10_000 }, async () => { | ||
| const { ctx } = await rig({ [`POST ${R}/issues/7/comments`]: { status: 502, body: { message: 'Server Error' } } }); | ||
|
|
||
| const result = await commentsToolModule.call('comment', { thread: 7, body: 'hello' }, ctx); | ||
|
|
||
| assert.equal(result.isError, true); | ||
| assert.match(text(result), /GitHub answered createComment with 502 \(Server Error\), so whether it took effect is unknown/); | ||
| }); |
There was a problem hiding this comment.
Medium severity, test coverage. Only comment is tested for the new comment-tool error result.
The change wraps all five comment tools in the uncertain-write catch, but the tests only exercise comment (a POST). Nothing covers edit_own_comment (PATCH), delete_own_comment (DELETE), reply_in_thread or the write half of upsert_sticky_comment with a lost answer or a 5xx. So the non-POST paths through isReadRequest, where a PATCH or DELETE should be marked possibly done, are untested. Add at least one PATCH case and one DELETE case.
| function isReadRequest(method: string | undefined, url: string | undefined, body: unknown): boolean { | ||
| const verb = method?.toUpperCase(); | ||
| if (verb === 'GET' || verb === 'HEAD') return true; | ||
| if (verb !== 'POST' || !url?.endsWith('/graphql')) return false; | ||
| let document = body; | ||
| if (typeof document === 'string') { | ||
| try { | ||
| document = JSON.parse(document); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| const query = (document as { query?: unknown } | null)?.query; | ||
| return typeof query === 'string' && /^(?:\s|#[^\n]*)*(?:query\b|\{)/.test(query); | ||
| } |
There was a problem hiding this comment.
Medium severity, test coverage. GraphQL read/write classification is tested only on two simple documents.
isReadRequest parses the body, handles a string or object body, skips leading whitespace and # comments, and treats { as a shorthand query. The only test (resolveReviewThread against listReviewThreads) uses documents that start with mutation and query with no leading whitespace or comments. Nothing tests a leading comment, the anonymous { form, a body that can't be parsed, or a non-/graphql POST. Since a wrong classification silently tells the agent a read 'may have taken effect', or tells it a mutation simply 'failed', the regex needs unit tests.
…ields - tools-comments: the comment tools log a write whose outcome is unknown, at warn level as runTool does for every other tool. Before, the error they threw reached the dispatcher's log. - tool-support: uncertainWrite reads possiblyDone, answered, status, operation and githubMessage off the error, as statusOf reads status, instead of importing the adapter's error class. The tool layer talks to the adapter only through types.ts again.
When a write tool's request to GitHub gets no answer, or a 5xx, the tool now tells the agent that whether the write took effect is unknown, and to look on GitHub before trying again. On
main,create_issueanswered that it failed, andcommentthrew an error, though GitHub may have stored the write. A read, and a write GitHub refused with a 4xx, are answered as onmain. This changes text an agent reads in a tool result.What the agent reads, from
uncertainWriteinsrc/tool-support.ts:<tool>: no answer came back from GitHub for <operation> (<cause>), so whether it took effect is unknown. Look on GitHub before trying again: a retry may do it twice.<tool>: GitHub answered <operation> with <status> (<GitHub's message>), so whether it took effect is unknown. Look on GitHub before trying again: a retry may do it twice.comment,reply_in_thread,edit_own_comment,delete_own_commentandupsert_sticky_comment) addIf it did, rollback has no record of it., and give this as an error result. Onmain,commentthrew such a failure, as the run below shows.<operation>is the adapter's name for the request, such ascreateCommentorcreateIssue, as onmain.What the tools gave on
main, in a run with the tests' fake GitHub server, on Linux with Node 24.22.1:create_issue, answer lost: an error result,create_issue failed: GitHub error 500: GitHub 500 on createIssue: other side closed.comment, answer lost: it threwGitHub 500 on createComment: other side closed. The server's dispatcher answers a thrown error that isn't anMcplRpcErrorwithsendError(req.id, ERR_INTERNAL, e.message)(src/server.ts:892), a JSON-RPC error, -32603, carrying that message.comment, a 502: it threwGitHub 502 on createComment: Server Error.In a run of the same probe at this branch's head,
72616ec, all three are error results with the texts above.Problem
On
main(c243333), when a write's answer is lost,create_issueanswers with an error andcommentthrows one, as the run above shows, though GitHub may have stored the write. In two of the new tests the fake GitHub server receives the write and then closes the connection. The 500 in those errors comes from octokit, not GitHub: the lockfile holds@octokit/request9.2.4, whosefetch-wrapper.js:48buildsnew RequestError(message, 500, { request: requestOptions })when a fetch throws. In the new tests onmain's code, the four that lose a write's answer, or answer it with a 502, fail.Changes
src/github-adapter.ts: a failure now carries whether its request only read, and whether GitHub answered at all.GETorHEAD, or when it's a GraphQL document that begins withqueryor{, after whitespace and#comments. Anything else counts as a possible write, including a request that can't be judged.src/tool-support.ts:describeFailuregives the texts above for a failure marked as possibly done.src/tools-comments.ts: the comment tools answer such a failure with an error result, and log it withctx.logger.warn('tool call failed', …). Their other failures are answered as before. They book a write for rollback only once GitHub answers, which is why their text adds the rollback sentence.src/server.ts: two comments.changelog.d/lost-answer.fixed.md.channels/publishis unchanged.handlePublishawaitsapi.createCommentwith nocatcharound it (src/channels.ts:644), and the dispatcher'scatchanswers a thrown error that isn't anMcplRpcErrorwithsendError(req.id, ERR_INTERNAL, e.message)(src/server.ts:892). agent-framework'spublishErrorOutcome(src/mcpl/channel-registry.ts:891–902at59d6095) returns anunknownoutcome, with "(delivery uncertain)", for an error answer to a publish, andfailedonly for a request that never left the host.Tests
test/lost-answer.test.tsis new, with 8 tests. They run against a fake GitHub server that can close the connection after it receives a request. Seven call a tool, and the GraphQL test calls the adapter's methods:upsert_sticky_comment's lookup whose answer is lost is a failed read, and nothing is written;get_issuereads as failed, andcommentthrows the mint's failure, not marked as possibly done, with no request for the repository sent;On Linux with Node 24.22.1, in clean checkouts after
npm ci:72616ec: typecheck succeeds, and the 131 tests intest/lost-answer.test.tsand the four tool test files (toolsComments,toolsIssuesPr,toolsReadandtoolsReview) pass.fe29c40: typecheck and build succeed.npm testruns 614 tests: 594 pass, 18 are skipped, and 2 fail. The 2 are the README tests intest/wp8-docs.test.ts, which needjqand a git checkout the machine lacked.maingives 606, 586, 18 and the same 2.main's code (d520453), the first four above fail, and the other four pass.In CI at
72616ec, onubuntu-latestwith Node 22.23.3, the test step runs 614 tests: 596 pass, none fail, and 18 are skipped.What this leaves
close_issue, which comments before it closes (src/tools-issues-pr.ts:178–179). None of this change's tests fails a later request after an earlier write in the same call.72616econ Linux with Node 24.22.1,create_issueagainst a closed port gavecreate_issue: no answer came back from GitHub for createIssue (connect ECONNREFUSED 127.0.0.1:34741), so whether it took effect is unknown. …. The text calls the outcome unknown, though the connection was refused.9127a61,5b73310and6d72345, each merge with this cleanly. Merge, update-branch, workflow runs and review reads, behind opt-in allow-lists #11 also changessrc/github-adapter.tsandsrc/server.ts.Ogden-2688 made the change, and Esther-2584 read it and ran it. Daniel-2612 wrote this description.