feat(#388): tell the operator when a cycle needs them - #389
Merged
Conversation
`/work <issues>` is meant to be fire-and-forget, but when a cycle parked, halted, or stopped awaiting a human merge, the only places that said so were the Pi scrollback and a JSON file — both of which require you to already be looking. `grep -rniE 'osascript|terminal-notifier|notify-send| webhook|slack'` over src/ returned nothing, and the GitHub comment + label existed only on the `runHandoff` path — not on a queue halt, not on `awaiting-human-merge` (#380), not on a driver crash. The realistic sequence: fire over eight issues, go to lunch, come back two hours later to find it stopped after twenty minutes on the second group. Everything needed to fix it was known at minute twenty. `PI_ENSEMBLE_NOTIFY_CMD` now runs on the four states that need a human — parked, queue halted, awaiting-human-merge, driver crash — once per group, never on a clean merge. A hook that fires on every event is noise, and noise is indistinguishable from no hook at all. Three deliberate constraints: **The transport is not our business.** The operator supplies a command and we run it: osascript, terminal-notifier, notify-send, say, curl to a webhook. Picking one for them means picking wrong for most of them. **Fail open, always.** A notification is an observer, and an observer that can break the thing it observes is worse than none. Missing binary, non-zero exit, and a hook that never returns are all swallowed after a 5s timeout and recorded only in the trace. The timer is unref'd so a notification is never the reason a process lingers. **Name the action, not the event.** "Issue #287 parked" is not actionable; "add acceptance criteria to #287" is. The message carries `humanActionFor`'s wording rather than inventing its own, and is two lines because every notification system truncates. The message travels on stdin and via $PI_ENSEMBLE_NOTIFY_MESSAGE, never interpolated into the command string — issue titles and provider error text are untrusted input, and building a shell line out of them would be an injection seam in the one component whose whole job is to be harmless. Tests: the load-bearing ones are not that it fires but that a completely broken hook leaves the queue entries BYTE-IDENTICAL. Canary: making a non-zero exit throw instead of resolving does not merely fail the suite — it HANGS it forever, which is exactly the walk-away failure mode fail-open exists to prevent. Closes #388
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.
Closes #388.
The problem
/work <issues>is meant to be fire-and-forget. But when a cycle parked, halted, or stopped awaiting a human merge, the only places that said so were the Pi scrollback and a JSON file — both of which require you to already be looking.grep -rniE 'osascript|terminal-notifier|notify-send|webhook|slack' extension/src/returned nothing. The GitHub comment +needs-human-attentionlabel existed only on therunHandoffpath — not on a queue halt, not onawaiting-human-merge(#380), not on a driver crash.So the realistic sequence was: fire over eight issues, go to lunch, come back two hours later to find it stopped after twenty minutes on the second group. Everything needed to fix it was known at minute twenty.
What this adds
PI_ENSEMBLE_NOTIFY_CMDruns on the four states that need a human — parked, queue halted, awaiting-human-merge, driver crash — once per group, and never on a clean merge. A hook that fires on every event is noise, and noise is indistinguishable from no hook at all.Three deliberate constraints:
The transport is not our business. The operator supplies a command and we run it. Picking one for them means picking wrong for most of them.
Fail open, always. A notification is an observer, and an observer that can break the thing it observes is worse than none. Missing binary, non-zero exit, and a hook that never returns are all swallowed after a 5s timeout and recorded only in the trace. The timer is
unref'd so a notification is never the reason a process lingers.Name the action, not the event. "Issue #287 parked" is not actionable; "add acceptance criteria to #287" is. The message carries
humanActionFor's wording rather than inventing its own, and is two lines because every notification system truncates.The message travels on stdin and via
$PI_ENSEMBLE_NOTIFY_MESSAGE, never interpolated into the command string. Issue titles and provider error text are untrusted input, and building a shell line out of them would be an injection seam in the one component whose whole job is to be harmless — there's a test firing"; touch /tmp/pwned; echo "as a park reason and asserting the command string stays verbatim.Testing
The load-bearing assertions are not that it fires. They're that a completely broken hook (a binary that doesn't exist,
|| exit 7) leaves the queue entries byte-identical to a run with no hook at all — compared by serialising both.The canary is the interesting one. Making a non-zero exit
throwinstead of resolving does not merely fail the suite — it hangs it forever, because the throw escapes theclosehandler and the promise never settles. That is exactly the walk-away failure mode fail-open exists to prevent, reproduced.Also covered: unset spawns nothing at all (byte-identical to pre-#388), whitespace-only counts as unset, a hanging hook is timed out in seconds not minutes,
awaiting-mergereads as finished-and-waiting rather than as a failure, and a clean merge produces no park to notify on.Quality gate
bun run build✅ ·bunx tsc --noEmit✅ ·bun run check✅ · 79/79 offline smoke tests ✅