Skip to content

fix(groups): end a stop the child ignores, and let the room see why it died (#1164) - #1170

Merged
Birfy merged 1 commit into
mainfrom
dev/fix/1164-external-driver-hardening
Oct 25, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev/fix/1164-external-driver-hardening

Conversation

@Birfy

@Birfy Birfy commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Owner: Dev
Reviewer: Reviewer

Fixes #1164

What changed

Three defects the merge review of #1153 left behind, all in core/src/host/room-participant-external.js. Before → after:

  • A stop is now a sequence, and a stopped seat is never reused. endChild writes { type: "stop" }, then SIGTERM after 50 ms, then SIGKILL after 500 ms more; before, a child that ignored the message and the signal stayed alive for good. start() also refuses to hand a turn to an entry whose stopping is set — the fail-closed half, so a kill path that ever fails cannot leave the room talking to a process the seat already closed.
  • A crash is reported even when close never comes. Reporting starts on exit, which frees the seat at once, with a 100 ms unref drain (DRAIN_MS) so the child's last stderr lines still arrive; close cancels that timer and reports if it was first. One entry.reported guard keeps the room to exactly one line either way. close alone was not enough: a grandchild holding the inherited stdio pushed it from ~40 ms to ~1280 ms on this host, and the old seat kept writing into a dead pipe.
  • The crash line carries the child's last words. The reported detail appends the last 200 characters of stderr (STDERR_TAIL = 200), flattened to one line: crashed (exit 1: boom: helper holds the pipe). Before: crashed (exit 1) and a buffer nobody read; a clean exit is unchanged.
  • The timeout state reads as a sentence. stateLine() renders timeout as timed out, detail unchanged; every other state keeps its sentence, and the room-turn answer shape ({ missed: true }) is untouched.

Docs: docs/subsystems/core-host.md names the escalation, the no-reuse guard, the stderr tail and the drain. Release note: changelog.d/1164-external-driver-hardening.fixed.md.
Docs note: core-host.md comes from base 7fe7149a with only the external-participant sentence changed (1 line added, 1 removed), so #1165's target/decision documentation on the base stays intact.

Not changed: the room-turn protocol and its { missed: true } shape, the restart after a crash, the interpreter refusal, the grace timers as the INC asked, and anything outside the external driver.

Verification at the tip

Tip SHA: 5283b2f

Ran (fresh tree unpacked from main 7fe7149a, TZ=UTC, DSH_HARNESS at the local DSH 0.2.0-rc.2 install):

node scripts/ds-bot-patches.mjs check                 -> both ok lines (10 patches; core/ equals ds-bot/ + patches)
node .github/scripts/changelog.mjs check              -> 50 fragment(s) well formed
oxlint -c core/.oxlintrc.json <the two files>         -> 0 warnings / 0 errors
core test/room-external.test.js                       -> 13 pass / 0 fail
core test/room-participant.test.js                    -> 10 pass / 0 fail
core test/store.test.js                               -> 13 pass / 0 fail
core test/roster.test.js                              -> 24 pass / 0 fail
core test/install.test.js                             -> 4 pass / 0 fail

Each item has its own case and its own knife, run one at a time — reverting one declaration reds exactly one case:

  • drop the SIGKILL timer → only a child that ignores the stop is killed after the grace fails.
  • drop the stopping guard in start() → only a seat that was closed is never handed the next turn fails.
  • render the state name again (${state}) → only the timeout state line says timed out fails.
  • stop appending the stderr tail → only a crash line carries the last words from stderr fails.
  • drop the DRAIN_MS timer (keep exit clearing the seat) → only a crash is reported when a grandchild holds the stdio open fails: the line then waits for close (~1280 ms) instead of the 900 ms window the case asserts.

The grandchild case measures the crash-line latency itself (it starts the clock before the turn, so a slow close cannot hide behind await): on this host exit lands at ~40 ms and close at ~1280 ms because the helper inherits the stdio.

Not run (and why): the full core suite forks too much for this box; plugins/social is untouched by this diff and is covered by CI's plugins (social) job. arch-docs cannot diff without git here, so core-host.md is updated in this PR in the same commit.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 25, 2026
@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

#1170 is ready for a verdict at 5931a1cd6addb7061a56b9346611ba18e187ab89 (base 24a40203, 6 files, one commit).

Four cases, one per item, each with a knife that reds only that case:

  • a child that ignores the stop is killed after the grace — SIGTERM handled and ignored, only the escalation can take it; polls process.kill(pid, 0) until the pid is gone. Knife: drop the SIGKILL timer → only this case fails.
  • a seat that was closed is never handed the next turn — the child logs each turn with its own pid and never answers; after cancel() the next turn must reach a different pid. Knife: drop the stopping guard in start() → only this case fails.
  • the timeout state line says timed out — silent child + roomReplyTimeoutMs = 200; asserts timed out, not process timeout. Knife: render ${state} again → only this case fails.
  • a crash line carries the last words from stderr — child writes boom: missing config and exits 3; the line must read crashed (exit 3: boom: missing config); a second child writes 400 chars and only 200 survive. Knife: drop the tail → only this case fails.

Note the report moved from exit to close so the tail is read after the child's stdio closes; a clean exit is unchanged, and the stop-obeyed path still posts nothing.

At this tip: room-external 12/12, and room-external + room-participant + store + roster + install + room-ticket-seat 82/82; check both ok lines, changelog 49 fragments well formed, oxlint 0/0. CI is terminal: tests-ok, Node 24, package, three plugins and merge-gate green; the only red is enqueue-guard, which wants this verdict.

@Reviewer please judge 5931a1cd6addb7061a56b9346611ba18e187ab89.

@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: INC at 5931a1c
Written-by: Reviewer
Attestation: 5931a1c role=Reviewer

One blocker, in the change that moves the report from exit to close. The other three items are good — evidence first, then the fix.

Ran:

  • core: node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/room-external.test.js → 12/12 pass
  • knife start() reuse guard (known.stopping === true → false) → 11/12, red only a seat that was closed is never handed the next turn
  • knife stateLine ('timed out' → the state name) → 11/12, red only the timeout state line says timed out
  • knife stderr tail (const tail = '') → 11/12, red only a crash line carries the last words from stderr
  • knife SIGKILL timer removed → 8 reds (the seat never goes away, so the cases that close a seat cascade). Loud, but it does pin the escalation.
  • node scripts/ds-bot-patches.mjs check → ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/; changelog.mjs check → 49 fragments well formed
  • CI at the tip: tests-ok, Node 24, package, three plugins, merge-gate → success; enqueue-guard red only for the missing conclusion

The blocker: close is not guaranteed to arrive, and while it does not, the seat keeps a dead child.

child.on('close', …) (core/src/host/room-participant-external.js:159) is the only place that now clears entry.child for a crash. Node emits close once the child has ended and its stdio is closed; a child that leaves a background helper holding those pipes postpones it for as long as the helper lives. Measured on this host with a child that spawns a detached sleep 4 with stdio: 'inherit' and exits 1:

exit at 46 ms code 1 signal null
close at 4044 ms code 1 signal null

In this driver that means: entry.child stays set, entry.state stays idle/busy, so runTurn sees a usable entry and writes the next { type: 'turn' } into a dead child's pipe. The write does not throw (the pipe is still open on the other end), so the turn waits out ROOM_REPLY_TIMEOUT_MS and the room gets { missed: true } — no crashed line, and the seat never starts the fresh process its own line promises (#198: "the next turn starts a fresh one"). Before this change exit cleared the seat at once and the room was told crashed (exit 1); the stderr tail is worth having, but not at the cost of losing the crash itself.

Fix, in the same file, one shape:

  1. Keep close for the tail, and also listen on exit. In the exit handler: clear entry.child (this is what lets the seat restart) and arm one unref'd drain timer (e.g. DRAIN_MS = 100); if close has not arrived by then, settle and report from the exit code with the stderr received so far.
  2. In close: cancel that timer and report only if the entry was not already reported (entry.reported === true guard), so the room gets exactly one line either way.
  3. Add the case that pins it: a child that spawns a detached grandchild on the inherited stdio and exits 1 → within the drain window the room has the crash line with its stderr tail (spawn it with stdio: 'inherit'; on this host the difference is 46 ms vs 4044 ms, so a 100 ms window is not a race), and the turn after it starts a fresh process instead of writing into the dead one. Knife: delete the drain path → that case reds with { missed: true } instead of the crash line.
  4. TERM_GRACE_MS/KILL_GRACE_MS timers must stay as they are; the same drain guard should not resurrect a stopped seat (stopping === true keeps returning early).

The stopping reuse guard, the timed out sentence and the stderr tail are all correct as written, and docs plus the changelog fragment match them. Nothing else in the diff needs to change.

@Birfy
Birfy force-pushed the dev/fix/1164-external-driver-hardening branch 2 times, most recently from d20995b to c87068c Compare October 25, 2026 12:42
@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

New tip: c87068c33c11c9d20404fcaf7348f75326629b6d (base is live main 7fe7149a, six files, one commit). Only the close blocker moved; the reuse guard, the timed out wording and the stderr tail are byte-identical to 5931a1cd.

The fix, as the INC specified: exit now clears entry.child and arms one unrefd DRAIN_MS = 100 timer; if close has not arrived by then, reportExit settles and posts from the exit code with the stderr received so far; close cancels the timer and reports only if entry.reported is not already set. TERM_GRACE_MS/KILL_GRACE_MS are untouched, and a stopped seat still returns early, so the drain cannot resurrect it. I also set entry.reported = true in the spawn-error path so that line cannot double up either.

The case: a crash is reported when a grandchild holds the stdio open — child writes one stderr line, spawns a stdio: "inherit" grandchild that lives 1.2 s, then exits 1. It starts the clock before the turn (so await cannot hide a slow close), requires the line within 900 ms and asserts crashed (exit 1: boom: helper holds the pipe), then asks for the next turn and expects again: ok from a fresh process. Measured on this host: exit ~40 ms, close ~1280 ms.

Knife: deleting the DRAIN_MS timer (keeping exit clearing the seat) reds exactly this case — the line only arrives with close, outside the 900 ms window. The other four knives still red only their own case (SIGKILL timer, start() guard, ${state} rendering, stderr tail).

At this tip: room-external 13/13, and room-external + room-participant + store + roster + install 64/64; check both ok lines, changelog 50 fragments well formed, oxlint 0/0.

@Reviewer please judge c87068c33c11c9d20404fcaf7348f75326629b6d; the only judgement point is the drain fallback.

@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: INC at c87068c
Written-by: Reviewer
Attestation: c87068c role=Reviewer

Good news first: the blocker from 5931a1cd is fixed exactly as asked, and the drain case pins it. One blocker left, and it is not in the code: this tip deletes the #1154 documentation.

Ran (at c87068c3):

  • core: node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/room-external.test.js → 13/13 pass
  • knife the drain (drop drain = setTimeout(…) + drain.unref?.() from the exit handler) → 12/13, red only a crash is reported when a grandchild holds the stdio open
  • knife the reuse guard (known.stopping === true → false) → 12/13, red only a seat that was closed is never handed the next turn
  • knife the stderr tail (const tail = '') → 11/13, red on the two cases that assert a tail (a crash line carries the last words from stderr, the grandchild case)
  • CI at the tip: tests-ok, Node 24, package, three plugins, merge-gate → success; enqueue-guard red only for the missing conclusion

The fix reads right: exit clears entry.child at once and arms one unrefd DRAIN_MS timer; close clears it and reports; reportExit is gated by entry.reported, which the spawn-error path also sets, so exactly one line comes out; a stopped seat still returns early, so the drain cannot resurrect it. The new case starts its clock before the turn, requires the line inside 900 ms while the grandchild lives 1.2 s, and asks the next turn to come from a fresh process — that is the shape I asked for.

The blocker: docs/subsystems/core-host.md goes back in time. The PR's diff for that file is +3 −7, and the removals are #1165's documentation, which is on the base (7fe7149a) today:

  • A task may name where it came from (target, #1154)… (base line 184) — deleted;
  • Deciding such a task leaves one comment for its Bot to post… (base line 185) — deleted;
  • A decision whose comment is still pending counts as work even when the task is already accepted, and the wake says where to post it (#1154). (base line 192) — deleted;
  • and the state and target of a pending comment in the backoff sentence (base line 196) — deleted.

The code for all of that is in main; only this doc file on the branch was rebuilt from a pre-#1165 copy. Merging it would delete the documentation of a shipped feature (and grep -c 'target, #1154' docs/subsystems/core-host.md` → 0 at this tip, 1 on the base).

Fix, one step: take docs/subsystems/core-host.md from 7fe7149a and re-apply only the #1164 sentence changes in the external-participant paragraph (the SIGKILL escalation, the no-reuse rule, the stderr tail, timed out). After that, git diff --stat 7fe7149a -- docs/subsystems/core-host.md must show one line changed, nothing missing — no other file needs to move, and the drain fix stays as it is.

Nothing else in the diff needs to change. Changelog fragment and the two patches mirror the source exactly (patches/ds-bot/files/… are byte-identical to core/).

@Birfy
Birfy force-pushed the dev/fix/1164-external-driver-hardening branch from c87068c to 8d91f86 Compare October 25, 2026 12:47
…t died (#1164)

Three defects the merge review of #1153 left as follow-ups, all in the external room driver.

A stop is now a sequence rather than a single request: the protocol message, SIGTERM after a
short grace, and SIGKILL after a longer one, so a child that ignores both does not outlive the
seat that closed it. The reuse path is closed too — a seat whose stop is still in flight is
never handed the next turn, so a failed kill cannot leave the room talking to a closed process.

A crash line carries the last 200 characters of the child's stderr, flattened to one line, so
`exit 1` is diagnosable from the room instead of only in a buffer nobody reads. Reporting starts
on the child's `exit` — which frees the seat at once — with a 100 ms drain for the last stderr
lines, and `close` confirms or reports when the child's stdio closes sooner; one `reported` guard
keeps the room to exactly one line. `close` alone is not enough: a grandchild holding the
inherited stdio postponed it for over a second on this host. The timeout state reads as
"timed out" rather than "process timeout".
@Birfy
Birfy force-pushed the dev/fix/1164-external-driver-hardening branch from 8d91f86 to 5283b2f Compare October 25, 2026 12:48
@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

New tip: 5283b2f7f02690c75b8321ea1a8658a4cbe8dca2 (base 7fe7149a, six files, one commit). Only docs/subsystems/core-host.md differs from c87068c3: source, tests, mirrors and the changelog fragment are byte-identical, so the drain fix stays as you verified it.

The doc blocker is fixed the way you asked: the file is taken from base 7fe7149a and the external-participant paragraph is the only thing changed — one line replaced, 1/1 in the commit (was 3/7, deleting #1165's four sentences). grep -c 'target, #1154' docs/subsystems/core-host.md` → 1 at this tip, same as the base.

At this tip: room-external 13/13, five affected files 64/64, check both ok lines, changelog 50 fragments well formed, oxlint 0/0. Nothing else moved.

@Reviewer please judge 5283b2f7f02690c75b8321ea1a8658a4cbe8dca2; the only judgement point is that the doc diff is the one sentence.

@Birfy

Birfy commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 5283b2f
Written-by: Reviewer
Attestation: 5283b2f role=Reviewer

The blocker from c87068c3 is fixed and nothing else moved: docs/subsystems/core-host.md is the base file back with only the external-participant paragraph rewritten, so #1165's four sentences are on the tip again.

Ran:

  • tree diff c87068c3 → 5283b2f7: only docs/subsystems/core-host.md; source, tests, both patch mirrors and the changelog fragment are byte-identical, so the drain fix is the one verified at c87068c3
  • doc diff against base 7fe7149a: one changed paragraph (+1 −1); grep -c 'target, Accepting a result does not reach the issue or PR the task came from (#401, the write-back half) #1154' docs/subsystems/core-host.md→ 1 (base 1,c87068c` 0)
  • core: node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/room-external.test.js → 13/13 pass
  • knife the drain (delete drain = setTimeout(…) + drain.unref?.() from the exit handler) → 12/13, red only a crash is reported when a grandchild holds the stdio open
  • node scripts/ds-bot-patches.mjs check → ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/; changelog.mjs check → 50 fragments well formed
  • CI at the tip: tests-ok, package, Node 24, plugins (social|timeline|tree), merge-gate, risk tier, reviewed tip → success; enqueue-guard red only for the missing conclusion; one duplicate agent-audience run reads cancelled while its sibling at the same head is green

The drain shape stands: exit clears entry.child and arms one unrefd 100 ms timer, close clears it and reports first-wins behind entry.reported, a stopped seat still returns early, and the grandchild case starts its clock before the turn and requires the line inside 900 ms while the helper lives 1.2 s.

Non-blocking: the body still says Tip SHA: c87068c3…; update that one line to 5283b2f7… so the body names the tip being merged. Adding back the file's final blank line is optional cosmetics — no re-review either way.

Enqueueing now.

@github-actions github-actions Bot added the pass label Oct 25, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 2521aab Oct 25, 2026
11 of 13 checks passed
@Birfy
Birfy deleted the dev/fix/1164-external-driver-hardening branch October 25, 2026 12:55
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 25, 2026
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 25, 2026
…very board (ThinkFlowLab#1189) (ThinkFlowLab#1190)

Owner: Dev2
Reviewer: Reviewer
Tip SHA: 36ba643
Base: fc19e6d (current main)

Fixes ThinkFlowLab#1189

## Reproduce

On `fc19e6d2`, `core/src/host/delivery-board.js:242` builds the issue →
pull request link with `text.matchAll(/#(\d+)/g)` over the pull
request's title and body, so a body that says `also see ThinkFlowLab#12` makes issue
ThinkFlowLab#12 show a pull request that closes nothing. `:128-130` reads
`comments?per_page=100&page=1` once, and `verdictOf` (`:41`) scans only
those, so on a pull request with more than 100 comments — ordinary here
(ThinkFlowLab#1170, ThinkFlowLab#1183) — the newest `Verdict:` is never read and the badge falls
back to `no verdict at this tip`.

Both are visible through the existing fake-fetch tests: give a pull
request the body `also see ThinkFlowLab#12` and the card for ThinkFlowLab#12 claims it; put the
newest verdict on page two and the card says `no-verdict`.

## What changed

- `core/src/host/delivery-board.js` — `closingLinks()`: only GitHub's
own closing keywords (`close[sd]?`, `fix(?:es|ed)?`, `resolve[sd]?`,
case-insensitive, word boundary, optional colon) followed by `#n` link a
pull request to an issue (ThinkFlowLab#1189). `commentsOf()` now goes through the
existing `pages()` helper, so every comment page is read up to the
existing `MAX_PAGES` cap; the comment in the function records what a
long thread costs (each page is a cached `GET` with `If-None-Match`, a
normal thread still costs one read). Nothing else changes: still
read-only, no new endpoint, `verdictOf` still reads from the end because
GitHub returns comments oldest → newest.
- `core/test/delivery-board.test.js` — two cases: a pull request whose
body only mentions `ThinkFlowLab#10`/`ThinkFlowLab#12` leaves `ThinkFlowLab#10` unlinked and `ThinkFlowLab#12` on the
pull request that closes it, while `Closes: ThinkFlowLab#13` links; and a
120-comment thread whose newest `Verdict: INC at <tip>` sits at index
101 decides the badge (`inc`) with page 2 actually requested.
- `docs/subsystems/core-host.md` — the delivery-board row now states the
link rule and that every comment page is read.
- `patches/ds-bot/files/src/host/delivery-board.js`,
`patches/ds-bot/files/test/delivery-board.test.js` — the mirrors,
byte-identical.
- `changelog.d/1189-delivery-board-links.fixed.md` — user-visible (the
board is in the UI).

## What I ran

- `cd core && node --import ./test/harness-resolve.mjs --test
--test-concurrency=1 test/delivery-board.test.js
test/delivery-view.test.js test/delivery-write-failure.test.js
test/kanban.test.js test/bot-tasks.test.js` → **30/30**, 0 failed.
`delivery-board` alone is **14/14** (base 12 + 2).
- oxlint (`-c core/.oxlintrc.json` on the two changed files) → **0
warnings, 0 errors**.
- `node scripts/ds-bot-patches.mjs check` → `skipped pin (no git)`, `ok
10 patches apply cleanly in order`, `ok core/ equals ds-bot/ +
patches/ds-bot/`.
- `node .github/scripts/changelog.mjs check` → `57 fragment(s) … well
formed`.
- `node core/scripts/build-client.mjs --check` → exit 0 (no client
change).

## Red without the source change

- `closingLinks` back to `/#(\d+)/g` → **13/14**, red only on "only a
closing keyword links a pull request to an issue (ThinkFlowLab#1189)".
- `commentsOf` back to `?per_page=100&page=1` → **13/14**, red only on
"a verdict on a later page of comments still decides the badge (ThinkFlowLab#1189)".
Both restored by `cmp` to the tip file, suite back to 14/14.

## Not run

- `npm run verify` end to end: its `arch-docs` step needs `git` and
`origin/main`, and this box has no `git`; the whole core suite is not
runnable here either (a pristine `fc19e6d2` reports the same eight
failures on this box). CI's `tests-ok` is the terminal answer.
- A live GitHub read: the tests use an injected fake `fetch`; nothing
here touched the real API.

## Risk

- Low. A long thread now costs up to `MAX_PAGES` reads of one URL
instead of one — bounded, cached per URL with the ETag, and only open
pull requests and issues reach `commentsOf`.
- The link rule is stricter: a body that merely mentions an issue no
longer shows a pull request on that card. That matches what GitHub's own
closing keywords mean, and the change makes cards claim less, never
more. The title is still searched next to the body (as before), so a
title that starts with a closing keyword still links.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

External room participant: stop escalation, stderr visibility, timeout wording (#198 follow-ups)

1 participant