Skip to content

feat(#388): tell the operator when a cycle needs them - #389

Merged
randomm merged 1 commit into
mainfrom
feat/issue-388-notify
Aug 7, 2026
Merged

feat(#388): tell the operator when a cycle needs them#389
randomm merged 1 commit into
mainfrom
feat/issue-388-notify

Conversation

@randomm

@randomm randomm commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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-attention label existed only on the runHandoff path — not on a queue halt, not on awaiting-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_CMD runs 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.

export PI_ENSEMBLE_NOTIFY_CMD='terminal-notifier -title "pi-ensemble" -message "$PI_ENSEMBLE_NOTIFY_MESSAGE"'
export PI_ENSEMBLE_NOTIFY_CMD='notify-send "pi-ensemble" "$PI_ENSEMBLE_NOTIFY_MESSAGE"'
export PI_ENSEMBLE_NOTIFY_CMD='curl -sS -d @- https://ntfy.sh/your-topic'

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 throw instead of resolving does not merely fail the suite — it hangs it forever, because the throw escapes the close handler 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-merge reads 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 ✅

`/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
@randomm
randomm merged commit 8030d39 into main Aug 7, 2026
1 check passed
@randomm
randomm deleted the feat/issue-388-notify branch August 7, 2026 08:01
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.

feat(work-driver): nothing reaches the operator outside the Pi session

1 participant