Skip to content

fix(perch, pi-bots): a bot that needs you waits for you, and pings you once - #534

Merged
kh0pper merged 4 commits into
mainfrom
fix/bot-needs-you-flood
Oct 20, 2026
Merged

kh0pper merged 4 commits into
mainfrom
fix/bot-needs-you-flood

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Why

A bot in one Perch session sent 44 high-priority "needs you" pushes (62 across a weekend), the closest 8 s apart. Two things combined:

  • Every ask card sent its own push, with no de-dupe.
  • The bot's approval dialogs gave up after 2 minutes and counted silence as a "no". The bot then tried another command, which opened another dialog and sent another push. When the operator approved one later from the phone, the dialog was already gone: the answer was dropped silently and the bot had moved on.

A third cause: the bash safety check treats any path under ~/.crow as sensitive. That includes the bot's own project workspace, so routine commands there asked for approval.

This PR is the crow half. The pi-lab half (no "no" on a timeout, parking instead, and the workspace exemption itself) ships separately, against a written contract. Either half can ship first.

What changes

One push per question episode (servers/shared/notifications.js, servers/gateway/perch-interactive.js)

  • The first ask card in a session writes one "needs you" row and sends one push.
  • Further cards while that row is unread update the same row: the title becomes "… needs you (N)", the body shows the latest question, and every question is listed in metadata.group (the newest 50 are kept, and older ones are counted).
  • created_at is left alone, so the Android app's created_at > since poll does not alert again on an update.
  • A trailing reminder is sent at most once per PERCH_ATTENTION_REPUSH_MS (default 10 min). It goes out only while a card is still waiting and the row isn't snoozed. The last push time and count are stored on the row, so a gateway restart doesn't reset them.
  • Answering, cancelling, aborting or stopping closes the group (the row is marked read), so the next question pushes again.
  • createNotification behaves as before. Its push fan-out moved into an exported deliverNotification.

A waiting question pauses the session instead of turning into a "no" (perch-interactive.js)

  • Perch children get PI_BOT_INTERACTIVE_ASK_TIMEOUT_MS=2147483647, the largest Node timer value. A pi-lab that still denies on timeout now waits about 24.8 days instead of 2 minutes.
  • A card expires in two cases:
    • an older pi-lab: the dialog request carries a timeout, and crow expires the card 2 s before it;
    • a newer pi-lab: it parks the turn and sends crow-state:{"kind":"ask-parked","reason":"timeout"|"aborted"}.
  • An expired card stays visible, read-only, with "This request expired. Nothing was run. Send a message to continue." (en/es). The session still shows as waiting (Perch "waiting on you", Bot Board "waiting"), and the card is replayed to clients that reconnect.
  • A late answer returns 409 request_expired. Nothing is sent to pi and nothing runs.
  • A parked turn ends silently, like an operator abort: no reply, no error frame, and no "replied" or "turn failed" push. It is still metered and audited.
  • An expired card no longer holds the stall watchdog or the idle timer paused. abort() clears it, and the next message clears it.
  • A waiting session keeps its child awake and holds one PIBOT_MAX_PI slot, so background jobs may queue behind it. This is documented in docs/developers/bot-engine.md.

The board doesn't start a second turn on a waiting card (servers/gateway/routes/bot-board-api.js)

  • /card/:id/execute returns 409 waiting_on_operator while a Perch session attached to the card has a live question, or an expired one on an awake child.
  • Perch sessions stay outside the lock rail otherwise, unchanged.

The project's workspace, for pi-lab's bash check (scripts/pi-bots/bot-workspace-env.mjs, bridge.mjs, bot-world.mjs, job_runner.mjs, instance-paths.mjs)

  • An interactive (Perch) child of a project-bound bot gets PI_BOT_WORKSPACE, PI_BOT_PROJECTS_ROOT and PI_BOT_ID. Channel and job children never do: a channel model reads untrusted mail, and a job runs bash as deny.
  • They are set only when the realpath'd folder is exactly <data>/projects/<one segment>/workspace and the bot id is a plain id. Otherwise none is set, and pi-lab then grants no exemption.
  • Any inherited copy (for example an operator's CROW_BOT_ENV_ALLOW) is deleted first. The keys carry the PI_BOT_ prefix, so a bot def's spawn_env can't set them, and spawn_env can't make a child interactive either.
  • Security review finding (MEDIUM): the workspace contains engine-private state (<ws>/bots/<id>/). Answer: <ws>/bots/<id>/ is each bot's engine world root. It holds sessions/ (pi transcripts, resumed on every wake), outputs/, uploads, .pi/ and .mcp.json. The pi-lab side keeps <ws>/bots/** entirely outside the exemption, the bot's own folder included, along with every dot-segment, and an older pi-lab ignores these keys. Since a Perch bot's default cwd is that same folder, routine relative paths never meet the .crow rule anyway; only absolute paths into it stay flagged, as today. The exemption skips one tripwire only: the classifier, write confinement and every other rule still apply.
  • No bridge exports changed.

Tests

  • New: tests/notifications-grouped.test.js, tests/perch-interactive-needs-you.test.js (the flood replay, grouping, serialised bursts, the re-push window, expiry, late answers, parking with and without the signal, ask_user abort, a park during a combined-question replay, reconnect replay, waitingOnCard), tests/bot-workspace-env.test.js (.. traversal, symlink escape, the root itself, wrong shape, bad ids).
  • Extended: tests/pibot-read-confinement.test.js (workspace keys reach channel and job children; a def's spawn_env can't set them) and tests/board-dispatch-job-rail.test.js (execute refuses a waiting card).
  • Updated: two extraEnv assertions and the fd-leak test in tests/perch-interactive.test.js, to match the grouped semantics.
  • Full suite (npm test): 10418 tests, 10381 pass, 35 skipped, 2 fail. Both failures are in files this PR doesn't touch (tests/artifacts-panel.test.js "live QW4 B2/B3" and tests/workspace-calendar.test.js "T10-I3"), and both pass when run alone. They look like load-timing flakes. The earlier run had only the artifacts one.

Reviews

  • Adversarial plan review: folded in. Parked turns are silent, created_at isn't bumped (the Android poll would re-alert), answering closes the group, snooze is respected, expired cards are replayed and abortable, and the workspace check is tightened to the exact shape.
  • Code review: folded in, then one re-review (no Critical or Important; its one small regression, a drifted combined card left its group open, is fixed).
  • Code review details: A snoozed reminder is retried, an expired card no longer lingers after a finished turn, a question that dies with its child closes its group, the combined-card drift keeps its group, and there are settleAsk/dropAsk helpers.
  • Security review: folded in. Workspace keys go to Perch children only and inherited copies are dropped, and a stray park signal can't silence a reply.
  • Accepted and documented: an unanswered question keeps its Perch child awake and holds a PIBOT_MAX_PI slot until it's answered, cancelled or aborted (operator decision; a crow-side park cap is the follow-up if it bites). A pre-existing, out-of-scope issue: the combined-question replay (danceStep) should only answer ask-user dialogs.

Deploy

  • Restart crow-gateway and crow-r4-gateway (engine, routes, notifications, clients).
  • scripts/pi-bots/ changed, so pibot-gateways@r4 needs a restart too. Note the timestamp.
  • No schema change and no bundle manifest bump.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

…RKSPACE / PI_BOT_PROJECTS_ROOT / PI_BOT_ID), fail-closed

Every bot child whose bot has a project workspace gets three engine-reserved
keys so pi-lab's bash check can stop treating the bot's own workspace as a
sensitive ~/.crow path. Set only for a real <data>/projects/<segment>/workspace
directory (realpath'd, so .. and symlinks can't escape); a def's spawn_env
can't set them (PI_BOT_ prefix is stripped).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ngs you once

- Ask cards are grouped per session: one high-priority "needs you" row and
  push while it is unread, updated in place with a count and every question;
  one trailing reminder per 10 min only while a card still waits; answering,
  cancelling, aborting or stopping closes the group.
- Perch children get PI_BOT_INTERACTIVE_ASK_TIMEOUT_MS=2147483647 so silence
  never becomes a "no". A request's own timeout (older pi-lab) or a
  crow-state ask-parked signal (newer pi-lab) expires the card: it stays
  visible read-only, the session still shows as waiting, a late answer gets
  409 request_expired and runs nothing, and the parked turn ends silently.
  An expired card no longer pauses the stall watchdog or blocks hibernation.
- Card execute refuses (409 waiting_on_operator) a card whose Perch session is
  waiting on the operator.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
- Workspace keys go to interactive (Perch) children only; channel and job
  children never get them, and an inherited copy is dropped first.
- A question that dies with its child, or that an older pi-lab already
  answered "no" before the turn finished, closes its group and stops
  holding the board card; a park signal only silences a turn when it
  expired a real card.
- A reminder held back by a snooze is retried when the snooze ends.
- One settleAsk/dropAsk helper per outcome; the combined-card drift path
  keeps its expiry and group.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
@kh0pper
kh0pper merged commit 71f3b36 into main Oct 20, 2026
4 checks passed
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.

1 participant