Skip to content

Fix the run-answer reader closing fd 4, and make the filesystem-task drop visible at the call site - #868

Merged
sroussey merged 2 commits into
mainfrom
claude/eloquent-gauss-fsvbal-cli-review
Aug 26, 2026
Merged

Fix the run-answer reader closing fd 4, and make the filesystem-task drop visible at the call site#868
sroussey merged 2 commits into
mainfrom
claude/eloquent-gauss-fsvbal-cli-review

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Two review findings in the CLI and @workglow/tasks.

1. The run-answer reader closed the parent's fd 4

readRunAnswerLines built its reader as fs.createReadStream("", { fd }), and the destroy() it returned as the stop function closed fd 4 — a descriptor the parent handed the child and that RunEventHumanConnector reopens once per outstanding question. So every prompt after the first opened a read stream on a closed descriptor, the error was swallowed by source.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 later open() (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 as EBADF far from the cause. installRunEventChannel in the same file already states the rule the read side was breaking: "A descriptor the parent handed us belongs to the parent."

autoClose: false is not sufficient — verified on Node 22: autoClose only controls autoDestroy, and an explicit destroy() closes the fd regardless. So the fd: branch now drives its own fs.read loop instead of a stream:

  • one read outstanding at a time, issued only while a listener is waiting — which is what still lets the child exit between questions (a read left outstanding on a pipe the parent holds open keeps the event loop alive, the original reason the reader is per-question);
  • the descriptor is never closed, so stopping and restarting is free;
  • reader state is now per descriptor rather than per question, so an unfinished line, a line that arrives between questions, and a multi-byte character split across two reads all survive a release.

The file: branch is unchanged — that stream owns the descriptor it opened.

Separately, RunRegistry.answerHuman returned { delivered: true } unconditionally while the write was still in flight, so the console reported success for an answer EPIPE had eaten. It now returns a Promise<boolean> resolved from the write callback; handler.ts awaits it.

2. registerCommonTasks() silently stopped registering the filesystem tasks

The export kept its name, signature and return type when FileGrepTask / FileLoaderTask / FileSedTask moved behind registerFileSystemTasks() — so the narrowing was invisible at every call site, and no caller of the new export existed outside its own tests. After upgrade workglow task run FileLoaderTask reports an unknown type, and any saved workflow whose serialized graph names one fails to deserialize. browser.ts still registered all three, so the builds disagreed about which type names resolve.

registerCommonTasks now takes a required { fileSystemTasks: boolean } in node.ts, electron.ts and browser.ts alike (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, and runWorkglowCli calls 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, asserts fstatSync(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 actual readRunAnswerLines (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 both answerHuman calls report delivery. Also "reports an answer to a run that already ended as undelivered".
  • Both of the above were confirmed to fail against origin/main's runEventChannel.ts and 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 naming FileLoaderTask still round-trips through createGraphFromGraphJSON.
  • RegisterFileSystemTasks.test.ts — extended for the option in both directions.

Verification

bun run format
  → All matched files use Prettier code style!

bun run build:types
  → Tasks: 42 successful, 42 total

bun scripts/test.ts cli task graph vitest
  → Test Files  164 passed | 1 skipped (165)
  →      Tests  2015 passed | 25 skipped (2040)

bun scripts/test.ts web scripts vitest
  → Test Files  6 passed (6)
  →      Tests  22 passed (22)

bun scripts/test.ts --check-sections
  → Every test file is discovered and reachable by section+kind selection.

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, false to narrow it deliberately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sit7PHmkEw5t3TpesT5g7s


Generated by Claude Code

claude added 2 commits August 26, 2026 08:43
`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
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 55.55% 35434 / 63783
🔵 Statements 55.27% 37178 / 67261
🔵 Functions 56.95% 6927 / 12163
🔵 Branches 44.43% 18141 / 40826
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
examples/cli/src/registerCliTasks.ts 100% 100% 100% 100%
examples/cli/src/run-events/RunEventHumanConnector.ts 83.87% 75% 80% 88.88% 43, 47, 58-63, 101
examples/cli/src/run-events/runEventChannel.ts 87.09% 71.42% 92.85% 91.35% 34-36, 62, 117-118, 134, 153, 177, 191, 204
examples/cli/src/web/RunRegistry.ts 82.64% 62.12% 75% 89.32% 125-126, 137-152, 170, 175, 183, 203, 205, 224, 234, 265, 279
examples/cli/src/web/handler.ts 53.91% 46.72% 75% 57.84% 54, 69-70, 99, 107, 112, 150-159, 164-183, 188, 197, 199, 220, 232-284
Generated in workflow #3305 for commit 7f52b93 by the Vitest Coverage Report Action

@sroussey
sroussey merged commit ba11ee8 into main Aug 26, 2026
15 checks passed
@sroussey
sroussey deleted the claude/eloquent-gauss-fsvbal-cli-review branch August 26, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants