Skip to content

fix(pi): join overlapping primary prompts instead of dropping them - #276

Merged
HelloWorldSungin merged 8 commits into
mainfrom
fm/fm-pi-prompt-collision-fix
Sep 14, 2026
Merged

HelloWorldSungin merged 8 commits into
mainfrom
fm/fm-pi-prompt-collision-fix

Conversation

@HelloWorldSungin

@HelloWorldSungin HelloWorldSungin commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Intent

The captain reported that the active Firstmate Pi session repeatedly displays Extension "<runtime>" error: Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion. After receiving the completed live diagnosis and the proposed bounded repair, the captain said: "I authorize that repair you have suggested."

Ship that authorized repair. Prevent overlapping Firstmate extension messages and captain messages from being dropped by serializing or safely retrying Firstmate's primary Pi message delivery. Prevent child model-list probes from falsely recording that the primary loaded a new extension generation. Make the supported activation rule accurate: after a Pi extension update, /new is not activation; /reload or a full Pi restart is required. Preserve the merged supervision continuity fix and its healthy successor behavior.

What Changed

  • Added .pi/extensions/lib/fm-pi-prompt-delivery.ts, which the watch extension installs. It wraps Pi's AgentSession._runAgentPrompt once per process, and later reloads reuse the same wrap. When a second prompt arrives while a turn is running, it joins that turn instead of failing with Agent is already processing a prompt: captain and other non-operational prompts are queued as steers, and Firstmate operational prompts and custom messages as follow-ups. Messages still left in the queue when the turn settles are started as a new turn, and a failure there is reported through the extension runner. A Pi build without this hook shows a prompt delivery unprotected warning at session start. Wake consumption now counts only the user message_start, no longer before_agent_start.
  • Added .pi/extensions/lib/fm-pi-loaded-marker.ts, shared by the watch and turn-end guard extensions. It only lets the process that holds the lock write the loaded-generation markers, or any process when no live process holds the lock. Both extensions now write markers only from session_start (or, in the watch extension, when it arms), never while the extension is being loaded. So a child pi --list-models probe can no longer record a newer build under its own pid.
  • Corrected the activation guidance: after a Pi extension update, /new, /resume, and /fork keep the old code, and only /reload or a full Pi restart loads the new code. This is updated in the fm-session-start.sh not-loaded message, the Pi harness reference, and the updatefirstmate skill. Also added unit tests for prompt delivery and the marker rule, a live test that sends overlapping prompts, a reworked Herdr hung-delivery test, and verification notes. The new suites are registered in fm-test-run.sh, and several existing suites got small test-setup changes, including a fixed umask for the quota suite.

🤖 Generated with Claude Code

Risk Assessment

⚠️ Medium: All three round-1 fixes check out against the installed Pi 0.85.1 source, and I found no new defect. The risk stays medium because the repair patches Pi's private AgentSession._runAgentPrompt on the prototype and depends on private fields (_steeringMessages, _followUpMessages, _emitQueueUpdate, _extensionRunner.emitError). Feature detection makes the patch fall back to Pi's own behavior if a future Pi changes any of these.

Testing

I drove real Pi 0.85.1 on this machine. On the base commit, the prompt-overlap TUI test reproduces the captain's exact banner and a dropped wake; on the fix, all four overlap orders (before and after /reload) deliver both messages in one turn with no banner and exactly one linked monitoring cycle. The rendered pane screenshots show both results. A base-vs-fix pi --list-models probe shows the base overwriting the holder's loaded markers while the fix leaves them untouched, and the loaded-marker and prompt-delivery test scripts pass against real Pi. A lifecycle probe shows /new reuses the same extension module while /reload loads a new one. The healthy-successor Herdr control passes. Temporary copies, the base extraction and the kept lab directory were removed, and the worktree is clean.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Captain types a prompt while a watcher wake is preflighting (and vice versa) in the real Pi TUI: both messages reach one model turn, no 'already processing' banner ✅ pass live prompt-collision-live-e2e.log; pane-fix-*.txt; pane-fix-after-reload-2.png
Check that the failure reproduces: the same overlap on base code shows Extension &#34;&lt;runtime&gt;&#34; error: Agent is already processing a prompt and drops the wake ✅ pass live base-prompt-collision-live-e2e.log; pane-base-timeout.png
After /reload, overlapping prompts are still delivered without a banner, and exactly one linked monitoring cycle keeps running ✅ pass live prompt-collision-live-e2e.log (after-reload-1/2 and linked-cycle lines)
Overlapping turn-end nudge, branch processing request, and captain prompt against a real Pi AgentSession are rejected without the delivery owner and delivered exactly once with it ✅ pass live bash tests/fm-pi-prompt-delivery.test.sh -> prompt-delivery.log
Primary's bash tool runs pi --list-models under the lock holder: loaded-generation markers stay exactly as the holder recorded them (base overwrites them with a new hash and the probe's pid) ✅ pass live list-models-probe-base-vs-fix.txt; loaded-marker.log
Activation rule: /new re-runs the already-loaded extension module, and only /reload loads a fresh copy ✅ pass live activation-new-vs-reload.txt
Supervision continuity: the healthy settlement control in a named Herdr lab hands every close to a successor, keeps a fresh beacon, and runs one monitoring cycle ✅ pass live herdr-healthy-successor.log

Base (before fix) real Pi TUI shows the captain-reported banner
Fixed real Pi TUI after /reload: overlapping captain prompts and wakes answered in shared turns, no banner

Evidence: Base collision run log (fails: wake never reaches a model turn)
not ok - timeout waiting for before-reload-1 wake to reach a model turn (event 'user wake-signal' x1)
Evidence: Fixed collision live test transcript
ok - real Pi 0.85.1 TUI (tmux): captain-first overlap (before-reload-1) delivers the captain message and the watcher wake in one turn with no banner
ok - real Pi 0.85.1 TUI (tmux): wake-first overlap (before-reload-2) delivers the captain message and the watcher wake in one turn with no banner
ok - real Pi 0.85.1 TUI (tmux): captain-first overlap (after-reload-1) delivers the captain message and the watcher wake in one turn with no banner
ok - real Pi 0.85.1 TUI (tmux): wake-first overlap (after-reload-2) delivers the captain message and the watcher wake in one turn with no banner
ok - real Pi 0.85.1 TUI (tmux): overlapping prompts kept exactly one linked monitoring cycle across /reload

all fm-pi-prompt-collision-live-e2e tests passed
kept lab: /tmp/fm-pi-prompt-collision-live-e2e.1TF1GQ
Evidence: Fixed pane captures per overlap

 pi v0.85.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  companion.ts, fm-primary-pi-watch.ts, fm-primary-turnend-guard.ts


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.


 CAPTAIN_WARMUP control


 REPLY_SEES CAPTAIN_WARMUP


 CAPTAIN_before-reload-1 overlap probe


 REPLY_SEES CAPTAIN_WARMUP+CAPTAIN_before-reload-1


 ⁣FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: signal: /tmp/fm-pi-prompt-collision-live-e2e.XhxjnG/home/state/collision.status

 Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.


 REPLY_SEES CAPTAIN_before-reload-1+wake-signal

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/tmp/fm-pi-prompt-collision-live-e2e.XhxjnG/project
0.2%/64k (auto)                                                                                                                                                                            deterministic
Evidence: pi --list-models probe marker contents, base vs fix
===== BASE ea6b934a
holder pid 993223 (tree: /tmp/fm-base-6s0l)
--- before probe
sha256:stale-watch
993223
sha256:stale-turnend
993223
probe rc=0
--- after pi --list-models probe
sha256:0182a2ea027c4f4949435bfd4e49ee3922af7c5dcd9a43c8166db56e167050ae
993234
sha256:163e521a1560f6710d4d7698466f4e6d99c45d6b8283fb1f605b3348a44be64b
993234

===== FIX 3ea59996
holder pid 993603 (tree: ~/.no-mistakes/worktrees/bc8432f7c9f8/01M2FB2N9X1YBFPZWFWQ8ZN29W)
--- before probe
sha256:stale-watch
993603
sha256:stale-turnend
993603
probe rc=0
--- after pi --list-models probe
sha256:stale-watch
993603
sha256:stale-turnend
993603
Evidence: Real Pi /new vs /reload extension module load IDs

factory gen=32f87v session_start reason=startup gen=32f87v factory gen=32f87v session_start reason=new gen=32f87v factory gen=pwb1vc session_start reason=reload gen=pwb1vc

real pi 0.85.1 lifecycle: startup, then /new, then /reload
factory gen=32f87v
session_start reason=startup gen=32f87v
factory gen=32f87v
session_start reason=new gen=32f87v
factory gen=pwb1vc
session_start reason=reload gen=pwb1vc
Evidence: Healthy-successor Herdr lab control
ok - isolated Pi healthy-settlement Herdr lab linked every close, kept a fresh beacon, and ran exactly one monitoring cycle

all fm-pi-hung-delivery-herdr-e2e tests passed
Evidence: Loaded-marker test with real pi probe
ok - watch and turn-end guard extensions record loaded evidence only from a started session run by the lock holder (or with no live holder), never at factory load, from a descendant, or over another live session
ok - a real pi 0.85.1 --list-models probe run under the lock holder leaves both loaded markers exactly as the holder recorded them

all fm-pi-loaded-marker tests passed
rc=0
Evidence: Prompt delivery test against real Pi AgentSession
ok - prompt delivery joins a running turn by shape (captain steers with context, Firstmate prompts follow up), starts a stranded join after settlement, installs idempotently, and reports a missing seam
ok - real Pi 0.85.1: an overlapping watcher wake, turn-end nudge, branch processing request, or captain prompt is rejected without the delivery owner and reaches one model turn exactly once with it, while non-overlapping prompts still run as separate turns

all fm-pi-prompt-delivery tests passed
rc=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ .agents/skills/harness-adapters/references/harness/pi.md - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ .pi/extensions/lib/fm-pi-prompt-delivery.ts:143 - A joined prompt is queued with agent.steer/agent.followUp directly, which skips Pi's own session queue bookkeeping (AgentSession._queueSteer/_queueFollowUp add the text to _steeringMessages/_followUpMessages and call _emitQueueUpdate). This causes two captain-visible problems in the real TUI. (1) A captain message that loses the preflight race leaves the editor but never appears in the pending-messages display, which reads session.getSteeringMessages(), so until the running tool batch ends it looks like the message vanished. (2) If the captain presses Escape during that turn, interactive-mode restoreQueuedMessagesToEditor({abort:true}) calls session.clearQueue(), which runs agent.clearAllQueues() (deleting the joined message) but returns only the session-tracked texts. The joined captain message is dropped silently and never put back in the editor, even though the stated goal is that captain messages are not dropped. Fix: when a joined message has role user, mirror Pi's queue bookkeeping (push its text into the matching session list and emit the queue update, as _queueSteer/_queueFollowUp do). Pi's message_start handler already removes those entries by exact text.
  • ⚠️ .pi/extensions/lib/fm-pi-prompt-delivery.ts:155 - The stranded-message restart is fire-and-forget (void wrapped.call(this, stranded)) with no rejection handler, and the Pi 0.85.1 CLI bundle installs no unhandledRejection listener. If that restarted _runAgentPrompt throws (for example Agent.prompt sees an activeRun from a continuation or retry started elsewhere, or an agent_settled handler throws out of _emitAgentSettled), Node's default unhandled-rejection mode ends the whole primary Pi process. Previously this was at worst a banner. Attach a catch that reports the failure through the same visible path rather than letting it escape as an unhandled rejection.
  • ⚠️ .pi/extensions/lib/fm-pi-prompt-delivery.ts:132 - The install check is keyed on a process-global symbol, so after /reload the wrap installed by the first load stays in place and keeps closures over the old module's code, including the old classifyFirstmateCurrentOperationalText. That conflicts with the activation rule this change documents in pi.md:56 ("Changed extension code activates only through /reload ... or a full process restart"). A later update to fm-pi-prompt-delivery.ts, or to the operational-input encoding it classifies with, does not take effect on /reload; only a restart applies it. Example: the encoding changes, /reload loads the new encoder into the watch extension, and the stale wrap classifies new wakes as non-operational, so they are joined as steers instead of follow-ups. Fix: store the unwrapped original per prototype in the global registry and re-wrap it with the current module's function on each install, so reload replaces the wrap without ever double-wrapping.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Captain types a prompt while a watcher wake is preflighting (and vice versa) in the real Pi TUI: both messages reach one model turn, no 'already processing' banner ✅ pass live prompt-collision-live-e2e.log; pane-fix-*.txt; pane-fix-after-reload-2.png
Check that the failure reproduces: the same overlap on base code shows Extension &#34;&lt;runtime&gt;&#34; error: Agent is already processing a prompt and drops the wake ✅ pass live base-prompt-collision-live-e2e.log; pane-base-timeout.png
After /reload, overlapping prompts are still delivered without a banner, and exactly one linked monitoring cycle keeps running ✅ pass live prompt-collision-live-e2e.log (after-reload-1/2 and linked-cycle lines)
Overlapping turn-end nudge, branch processing request, and captain prompt against a real Pi AgentSession are rejected without the delivery owner and delivered exactly once with it ✅ pass live bash tests/fm-pi-prompt-delivery.test.sh -> prompt-delivery.log
Primary's bash tool runs pi --list-models under the lock holder: loaded-generation markers stay exactly as the holder recorded them (base overwrites them with a new hash and the probe's pid) ✅ pass live list-models-probe-base-vs-fix.txt; loaded-marker.log
Activation rule: /new re-runs the already-loaded extension module, and only /reload loads a fresh copy ✅ pass live activation-new-vs-reload.txt
Supervision continuity: the healthy settlement control in a named Herdr lab hands every close to a successor, keeps a fresh beacon, and runs one monitoring cycle ✅ pass live herdr-healthy-successor.log
  • FM_PI_PROMPT_COLLISION_LIVE_E2E=1 bash tests/fm-pi-prompt-collision-live-e2e.test.sh (real Pi TUI in tmux, fixed branch)
  • Same collision test script run against a git archive ea6b934a extraction of the base code, as a check that it fails before the fix, with pane capture
  • Collision test copy that also saves the pane after each overlap on the fixed branch (temp copy, deleted afterwards), pane captures rendered to PNG with headless Chrome
  • bash tests/fm-pi-loaded-marker.test.sh (includes a real pi --list-models probe under the lock holder)
  • Manual base-vs-fix pi --list-models probe with stale holder markers, before/after marker contents recorded
  • bash tests/fm-pi-prompt-delivery.test.sh (real Pi AgentSession overlap with and without the delivery owner)
  • Manual real Pi TUI lifecycle probe: startup, then /new, then /reload, recording the extension module load ID each time
  • FM_PI_HUNG_DELIVERY_HERDR_E2E=1 FM_PI_SETTLEMENT_CONTROLS=healthy bash tests/fm-pi-hung-delivery-herdr-e2e.test.sh (named Herdr lab)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Sungin Kim added 8 commits September 14, 2026 06:54
Pi 0.85.1 decides between queueing a prompt and starting a new turn before
its preflight, so a watcher wake, turn-end nudge, or branch processing
request that overlaps a captain prompt inside one preflight window was
rejected with "Agent is already processing a prompt" and dropped, together
with a spurious settle of the still-running turn.

One owner, .pi/extensions/lib/fm-pi-prompt-delivery.ts, installed by the
watch extension, now joins such a prompt to the running turn through Pi's
own steer and follow-up queues, and starts a join that missed the turn's
last queue check once that turn settles. A wake is consumed only when a
model turn accepts it (its user message_start), never at
before_agent_start.

Loaded-generation markers are now written only from a started session run
by the lock holder itself, so a pi --list-models probe from the primary's
shell can no longer make stale extension code read as current. The
session-start diagnostic, Pi harness reference, and updater guidance now
state that /reload or a full restart activates changed extension code,
while /new, /resume, and /fork keep the cached factory.
…branch. (1) Behavior portable serial 2: tests/fm-live-gate.test.sh runs every live guard with FM_LIVE=0 and expects the shared refusal line. tests/fm-pi-prompt-collision-live-e2e.test.sh used its own env check, so it printed a different message. It now opens with `fm_live_gate opt-in FM_PI_PROMPT_COLLISION_LIVE_E2E pi` like tests/fm-pi-primary-live-e2e.test.sh, still needs its own variable to run, and keeps its tmux or herdr+jq checks when it does run. The sweep is unchanged: the hung-delivery guard is already listed there as keeping its own opt-in, and the primary guard already uses the shared gate. (2) Stock macOS Bash 3.2: tests/fm-pi-loaded-marker.test.sh had its Node script as a heredoc with apostrophes inside $(...), which Bash 3.2 can't parse. The script is now written to a file with a heredoc outside the command substitution, and $(...) only runs node on that file. The script and assertions are unchanged. Verified: fm-live-gate.test.sh passes (exit 0); fm-pi-loaded-marker.test.sh passes, including the real pi --list-models probe; the collision guard prints the shared refusal under FM_LIVE=0 and the opt-in message by default; ShellCheck via bin/fm-lint.sh is clean on both files. Bash 3.2 was not available locally. The macOS check reported only the marker test as failing to parse, and that file no longer has a heredoc inside $(...). Changes are not committed
@HelloWorldSungin
HelloWorldSungin merged commit 90ca53b into main Sep 14, 2026
32 of 33 checks passed
@HelloWorldSungin
HelloWorldSungin deleted the fm/fm-pi-prompt-collision-fix branch September 14, 2026 08:13
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