Repository navigation
fix: a ticket's end is written once, before what it decides is done - #980
Merged
Merged
Conversation
This was referenced Nov 8, 2026
Merged
kid7st
added a commit
that referenced
this pull request
Nov 8, 2026
## Release v0.30.1 Bump `@fastagent-sh/fastagent` from 0.30.0 to 0.30.1. Two fixes to 0.30.0; nothing else. ## Fixed - **An argument after the command that begins with `-v` printed the version and did nothing** (#981). commander read the program's `-v, --version` anywhere in the arguments by default, so `fastagent invoke "-very urgent: …"`, `--name -vendor` and `invoke --session <id>` with an id beginning with `-v` (1 storage in 4096) printed `0.30.0` and exited 0 having done nothing. The program's options are now read only before the command, as `docs/cli.md` says; an operand that begins with `-` after the command is refused as an unknown option (exit 2). - **A stopped or replaced step could report the wrong end** (#980). Stopping a ticket's step (`--check <ticket> --stop`), replacing it with a newer attempt, or giving up on its start signalled the step and wrote the reason after; a step that heard the consequences first (its host CLI killed by the same signal, as `signal-exit` lets it on Linux) recorded its own failure, and that one stayed. A ticket's end is now written once, before what it decides is done, and nothing the step writes after reopens it. ## Verification - `npm run lint`, `npm run typecheck`, `npm test` (195 files, 2382 passed, 2 skipped), `npm pack --dry-run --json`. - The ticket and CLI tests also passed three times under load in a Linux container (Node 26.13.0), where the step-ordering failure was measured. ## After merge Create and publish the GitHub Release `v0.30.1` from the merged commit, with the sections above as its notes. The protected publish workflow verifies and publishes the package to npm via Trusted Publishing. This PR itself does not tag or publish.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root causes of the ticket tests' intermittent failures, and the fixes. It also corrects #979, whose explanation of the failure that stopped v0.30.0's first publish was wrong.
Three causes
1. A test's fake step rewrote the ticket in place (the publish failure, and
ticket.test.ts"a newer …" on Node 26)SyntaxError: Unexpected non-whitespace character after JSON at position 215, inreadTicketasstartTicketpolled. The fake step inwaitingStepwrote the ticket withwriteFileSync(path, …), which truncates and rewrites the same file, while the starting command read it.readFileSync(path, "utf8")reads until EOF in several reads, so it can return the first bytes of the old ticket and then the tail of the new one: a whole JSON document followed by more. The ticket that test creates is exactly 215 bytes; the earlier failure says 213.Reproduced: a process rewriting a file in place, short then long, and another reading it, gives exactly this error, always at the short document's length (14 to 33 times in about 110,000 reads). With write-then-rename, 290,000 reads give none.
The product never writes a ticket in place (
writeFileAtomic), so this was the test. Its two fake steps now write the ticket whole, as a step's process does. #979 said the cause was twowriteFileAtomicwriters sharing a temp. That race is real, but it is not what happened here: this ticket had no secondwriteFileAtomicwriter.2. A stop's end lost to the end the stopped step wrote itself (
cli-deploy.test.ts"hands a signed-out host CLI's sign-in", twice on Node 26)stopTicketsignalled the step's process group and then wrotestopped on request. A step's process does not die at once on SIGTERM:proper-lockfileloadssignal-exit, which catches SIGTERM in JavaScript and re-raises it. On Linux, the step's event loop can deliver the exit of its host CLI (killed by the same signal) before that handler. The step then records its own failure (`railway login --browserless` did not sign in (exit 1);exitOfmaps a death by signal to 1), andupdatekeeps the first end, so the stop's was dropped. A person who stopped a deploy was told the host CLI had failed to sign in.Measured in a Linux container (Node 26.13.0, 4 CPUs): a process loading
signal-exit, its child in its group, the group sent SIGTERM; the step ran its child-exit handler before dying 3 times in 300, 13 in 300 under load. Withoutsignal-exit, 0 in 600. On macOS, 0 in 500 either way. Superseding a step (replaced by a newer attempt) and giving up on a start had the same order.3.
updateread, changed and wrote the ticket with nothing between (what #2 needs to be fixed for good)Writing the end first is not enough on its own: a step that read its ticket as running just before another process ended it would write its record after, and reopen it.
The fix
A ticket is two files: the step's own record, written by one process at a time (the starting command, then the step), and its end, created once with a hard link, which the kernel lets only one writer make.
readTicketreads the end over the record. An end is written before what it decides is done:stopTicket,supersedeand a start given up on now end the ticket, then stop the process. So the stop is the end even when the step hears the consequences first, nothing the step writes after reopens it, and two enders that both read the ticket as running end it once.pruneremoves every file of a ticket past its time, a crashed writer's draft included.writeTicketno longer needssharedWriters(#979): the record has one writer at a time. The.addresskeeps it, since two--check --codemay hand one over at once.Tests
runTicketthat records its own failure when told SIGTERM (what a step withsignal-exitdoes when it hears its child die first): a stop on request is its end; a newer attempt is the end of the one it replaces; a start given up on is its end.endTicket: a second end, by one that read the ticket running too, leaves the first.All four of the first group failed before the fix, for the reasons above (the step's own message won; the ticket was reopened to
need_user_action). Mutation checks, each failing a test: the process stopped before the end is written (stop, supersede), a start given up on not stopping its process, the end not read over the record, the end written by rename instead of a link. The ticket tests andcli-deploy/cli-loginalso passed three times under load in the Linux container.Found, not fixed here
running(pid)iskill(pid, 0), which succeeds for a zombie. In a container whose PID 1 does not reap (Docker without--init), a stopped step stays a zombie, sostopwaits its full 5 s and--checknever sees the step gone. CI's runner reaps, so it does not fail there. A separate change.