Fix the run-answer reader closing fd 4, and make the filesystem-task drop visible at the call site - #868
Merged
Conversation
`readRunAnswerLines` built its reader with `fs.createReadStream("", { fd })`,
and the `destroy()` it returned as the stop function closed fd 4 — a descriptor
the parent handed the child and that the connector reopens per question. Every
prompt after the first therefore read from a closed fd, the error was swallowed,
and the run hung. Worse, the freed number is handed straight back to the
process, so a later `open()` can land on 4 and the next release closes that
subsystem's file instead.
`autoClose: false` does not help: an explicit `destroy()` closes the fd anyway.
So the `fd:` path now drives its own `fs.read` loop — one read at a time, issued
only while a question is outstanding (which is what still lets the child exit),
and no close, ever. The unfinished line, a line that arrives between questions,
and a multi-byte character split across two reads all survive a release, since
the reader is now per descriptor rather than per question.
`RunRegistry.answerHuman` also reported delivery unconditionally while the write
was still in flight, so the console said an answer landed when EPIPE had eaten
it. It now resolves from the write callback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sit7PHmkEw5t3TpesT5g7s
`registerCommonTasks()` kept its name, signature and return type when the three
filesystem tasks moved behind `registerFileSystemTasks()`, so the narrowing was
invisible at every call site — and nothing outside its own tests called the new
export. `workglow task run FileLoaderTask` reported an unknown type after
upgrade, and any saved workflow naming one stopped deserializing, with no
migration visible from the API. `browser.ts` meanwhile still registered all
three, so the builds disagreed about which type names resolve.
`registerCommonTasks` now takes a required `{ fileSystemTasks }` in all three
entries, which turns the choice into a compile error until the host makes it,
and makes the entries agree about what they claim to register. The security
intent is unchanged: a host that does not ask still does not get them.
The CLI asks. `registerCliTasks()` is the one place the binary's task surface is
stated — its own module so that surface is assertable without standing up a
program, a config directory and a model repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sit7PHmkEw5t3TpesT5g7s
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
Two review findings in the CLI and
@workglow/tasks.1. The run-answer reader closed the parent's fd 4
readRunAnswerLinesbuilt its reader asfs.createReadStream("", { fd }), and thedestroy()it returned as the stop function closed fd 4 — a descriptor the parent handed the child and thatRunEventHumanConnectorreopens once per outstanding question. So every prompt after the first opened a read stream on a closed descriptor, theerrorwas swallowed bysource.on("error", () => {}), and no answer line was ever delivered: a run that asks two questions hung forever on the second. Once closed, the number is also handed back to the process, so a lateropen()(SQLite db, model cache, NDJSON log) can land on 4 — the second prompt then reads bytes from an unrelated file and the next release closes that subsystem's descriptor, surfacing asEBADFfar from the cause.installRunEventChannelin the same file already states the rule the read side was breaking: "A descriptor the parent handed us belongs to the parent."autoClose: falseis not sufficient — verified on Node 22:autoCloseonly controlsautoDestroy, and an explicitdestroy()closes the fd regardless. So thefd:branch now drives its ownfs.readloop instead of a stream:The
file:branch is unchanged — that stream owns the descriptor it opened.Separately,
RunRegistry.answerHumanreturned{ delivered: true }unconditionally while the write was still in flight, so the console reported success for an answer EPIPE had eaten. It now returns aPromise<boolean>resolved from thewritecallback;handler.tsawaits it.2.
registerCommonTasks()silently stopped registering the filesystem tasksThe export kept its name, signature and return type when
FileGrepTask/FileLoaderTask/FileSedTaskmoved behindregisterFileSystemTasks()— so the narrowing was invisible at every call site, and no caller of the new export existed outside its own tests. After upgradeworkglow task run FileLoaderTaskreports an unknown type, and any saved workflow whose serialized graph names one fails to deserialize.browser.tsstill registered all three, so the builds disagreed about which type names resolve.registerCommonTasksnow takes a required{ fileSystemTasks: boolean }innode.ts,electron.tsandbrowser.tsalike (RegisterCommonTasksOptions, in its own module). A required field is the point: there is no default that could quietly hand a host the filesystem tasks or quietly take them from a host whose stored workflows already name them. The containment rationale is preserved — a host that does not ask still does not get them;registerFileSystemTasks()remains for hosts that register in pieces.The CLI asks.
registerCliTasks()(examples/cli/src/registerCliTasks.ts) is now the one place the binary's task surface is stated, andrunWorkglowClicalls it — its own module so the surface is assertable without standing up a program, a config directory and a model repository.In-repo callers updated:
examples/web(was registering all three in the browser build — unchanged behaviour),packages/test's bindings and two graph tests (false, unchanged behaviour). Docs updated:packages/tasks/README.md,docs/technical/20-task-registry.md,.claude/CLAUDE.md.Tests
runEventChannel.test.ts— "leaves a descriptor it was handed open when the reader stops": reads through an fd the test owns, releases the reader, assertsfstatSync(fd)does not throw, then appends and reads again through the same fd.RunRegistry.test.ts— "answers a run that asks twice": a real child process asks two questions over a real fd 4, using the actualreadRunAnswerLines(Node strips the types out of the imported.ts, so the child runs the module under test rather than a copy). Asserts both answers round-trip and bothanswerHumancalls report delivery. Also "reports an answer to a run that already ended as undelivered".origin/main'srunEventChannel.tsand pass with the fix.registerCliTasks.test.ts— pins the CLI's registered surface: the three filesystem types, the utility types the commands are built on, and that a graph namingFileLoaderTaskstill round-trips throughcreateGraphFromGraphJSON.RegisterFileSystemTasks.test.ts— extended for the option in both directions.Verification
Note for downstream embedders (builder, sec):
registerCommonTasks()is now a compile error without the argument. That is the intended signal — pick{ fileSystemTasks: true }to keep the current surface,falseto narrow it deliberately.🤖 Generated with Claude Code
https://claude.ai/code/session_01Sit7PHmkEw5t3TpesT5g7s
Generated by Claude Code