Skip to content

feat(focus): single-channel focus mode with held backlog, autoreply, and expiry - #195

Open
antra-tess wants to merge 6 commits into
mainfrom
feat/focus-mode
Open

antra-tess wants to merge 6 commits into
mainfrom
feat/focus-mode

Conversation

@antra-tess

Copy link
Copy Markdown
Collaborator

What

A focus tool (opt-in via FrameworkConfig.focus.enabled): the primary resident narrows attention to one channel for a bounded time.

  • While focused: every other channel and DM is held — stored with metadata.focusHeld = { epochId }, excluded from the residents' view by the same viewFilter that excludes tune-out stamps, never woken on. Addressed messages elsewhere get one host-authored automatic reply per (channel, author) per epoch ([Automatic reply] … is in focus mode for about 25m more …; template configurable).
  • check reports held/addressed counts per channel and time remaining; with channelId it peeks that channel's newest held lines without releasing them.
  • Ends on end, at its deadline (durable; an expired epoch found at boot ends itself), or re-targets on a repeat enter (the new channel's held backlog is delivered immediately, deadline resets, same epoch).
  • Unfocus dump: [Focus ended — …] then a per-channel <focus-backlog channel=… messages=N tz=… truncated=M> of the newest backlogCap (default 20) held messages, plus a hint that names only tools the resident actually has (history--extract, channel_open {backscroll}, *fetch_history).

Sibling of tune-out (#77): same stamp-and-filter exclusion, same lifecycle-log durability (focus record in mcpl/channel-lifecycle, last-record-wins), same absolute-deadline timer pattern — but per-resident, no subconscious dependency, and an inverted predicate.

Things found on the way

  • Both inbound paths are hooked — DMs and addressed-while-closed messages arrive as push events, which tune-out's divert doesn't cover.
  • Gate hold: EventGate.setHoldPredicate() drops held events before policy evaluation and purges already-queued debounce/buffer events. Without this, a debounce policy queues the event inside the gate and later delivers a [Gate: N events] wake for traffic the framework is holding — tune-out has this leak today.
  • The closed-channel invitation block is not attached to held messages; its "reply this turn" affordance would be stale noise in the dump.
  • Tool dispatch is primary-resident only (a fork or side-agent calling it would narrow someone else's window).

Tests

  • focus-coordinator.test.ts (15): hold predicate, autoreply once-per-author, dump cap + hint, check/peek, re-target without double delivery, duration clamps, resume with expired/live deadline.
  • focus-lifecycle.test.ts (4): record durability/replay, malformed record → not focused.
  • focus-framework.test.ts (2): through the framework with a real MCPL child — both ingestion paths, gate hold + purge, compile exclusion, real in-channel autoreply, dump + wake.
  • Full suite: 937/944 vs 901/908 on a clean main — the 3 failures on each are mcpl-awareness-barrier startup-gating tests timing out (1 s tools/call) under full-suite load; 25/25 when the file runs alone.

Pairs with the connectome-host recipe plumbing (feat/focus-recipe).

🤖 Generated with Claude Code

…and expiry

The primary resident can narrow attention to ONE channel for a bounded
time (`focus` tool: enter / check / end). Every other channel and DM is
held: stored with `metadata.focusHeld = { epochId }`, excluded from the
residents' view by the same viewFilter that excludes tune-out stamps,
never woken on. Addressed messages elsewhere get one host-authored
automatic reply per (channel, author) per epoch. Focus ends on `end`,
at its deadline (durable in mcpl/channel-lifecycle; an expired epoch
found at boot ends itself), or re-targets on a repeat `enter`. Ending
delivers a per-channel <focus-backlog> of the newest `backlogCap` held
messages and points at the resident's actual history/backscroll tools.

- Hooked into BOTH inbound paths (channels/incoming and push/event —
  DMs and addressed-while-closed messages arrive on the latter).
- The closed-channel invitation is not attached to held messages: its
  "reply this turn" affordance would be stale noise in the dump.
- EventGate.setHoldPredicate() drops held events before policy
  evaluation and purges already-queued debounce/buffer events, so a
  debounce policy cannot later deliver a [Gate: N events] wake for held
  traffic.
- Opt-in via FrameworkConfig.focus.enabled; tool is offered to the
  primary resident only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5 Tier: apex

[Medium impact] The change affects how the primary resident receives and routes messages across connected channels while focus is active.

Do not merge yet; speech can reach the wrong server, and the earlier reply-recording and shared-history defects remain.

Findings

  1. P1 Security Explicit speech reaches wrong server ▶
  2. P1 Restart can duplicate automatic replies ▶
  3. P1 Reply recording failures escape ▶
  4. P1 Consumed messages become replaceable ▶
  5. P2 Empty cooldown wakes during focus ▶
Fix with agent prompt
### Issue 1
src/mcpl/channel-registry.ts:3198-3200
When two servers share a `channelId`, `>>>#alpha` can uniquely select the first server's room while focus names the second server's same-ID room. `deliverHybridProse` passes the bare ID, so this new branch sends the message through `focusLocus.serverId` instead of the selected server. The resident's words reach a different audience. Carry the selected server into `routeSpeech`; do not apply focus routing to explicit destinations.

**How this was verified:** A unique label resolves to the first room, but the matching-ID branch selects the focus server before `sendChannelsPublish`.

### Issue 2
src/focus/coordinator.ts:undefined-588
A published autoreply is recorded only after the server acknowledges it, and this path does not sync the record. If the process stops before that record is durable, the resumed focus epoch has no record of the reply. The next addressed message from the same author gets another automatic reply, breaking the once-per-author promise.

### Issue 3
src/focus/coordinator.ts:undefined-588
If recording an acknowledged autoreply fails, the error occurs outside the publication `try` block and rejects an async call whose result is discarded. The registry also adds the reply allowance to memory before appending its record, so the running host suppresses further replies from that author even though a restart cannot restore the allowance.

### Issue 4
src/framework.ts:undefined-7965
With the subconscious enabled, its unfiltered shared-slot view can consume a focus-held message during a tune-out wake or cadence. This unconditional “unread” result still lets a later coalesced edit or retraction remove that message in place rather than append a correction, changing history the subconscious has already read.

### Issue 5
src/framework.ts:1909-1910
If focus entry removes the last out-of-focus request from a provider cooldown, the cooldown remains active with no requests. At expiry, it creates a channel-less `provider-acceleration-retry`, costing an unnecessary primary turn during focus despite the wake purge.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds opt-in single-channel focus mode with held messages, automatic replies, deadline recovery, and capped backlog delivery.

  • The primary resident can focus on one channel and later receive the held backlog.
  • Plain speech stays in the focused channel until focus ends.

Reviews (4) · Last reviewed commit: "fix(focus): second review round — server..." · Reviewed by Greptile

Comment thread src/focus/coordinator.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/focus/coordinator.ts
Comment thread src/focus/coordinator.ts
Comment thread src/gate/event-gate.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/focus/coordinator.ts Outdated
Comment thread src/focus/coordinator.ts Outdated
Comment thread src/focus/coordinator.ts Outdated
Comment thread src/mcpl/channel-registry.ts Outdated

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at exact head 2036882. The shape is right — the stamp-and-filter reuse from tune-out, the strict pre-policy gate hold, ingestion covered on both inbound paths, and tests that drive the real framework and a real MCPL child rather than the coordinator alone. Suite 988/992 pass, typecheck clean, lifecycle replay / corrupt-record / restart-expiry / re-target release-point bookkeeping (including A→B→A) all read correct under probing. Two findings and some notes; red probes for both findings are at slimepriestess/agent-framework@3c6e586 (test/probe-review-195.test.ts, runs red against this head).

1. The enter-focus purge can't see a queued push event's channel, so it misses the exact window it exists for (framework.ts:1760-1771 + event-gate.ts setHoldPredicate).

The live predicate derives a push event's channel from the gate metadata — the spread origin (mcplChannelId, or source/guildId/channelId for the Discord shape). But PendingEvent keeps only the bare info.channelId, which for an mcpl-native push is '' (the channel rides in origin.mcplChannelId; there is no top-level channelId). The purge's reconstructed GateEventInfo therefore derives nothing, if (!channelId) return false declines to hold, and the queued wake survives. fireDebounce → deliverEvents doesn't re-check the hold predicate, so the survivor delivers inside focus.

Being precise about the impact so this reads no bigger than it is: no held content leaks — queued MCPL events are inContext, so the delivery is a [Gate: N events] reference line, and the stamping/view-exclusion is independent of the gate. The damage is one spurious wake per focus entry, naming a non-focus channel, landing inside the window the resident just paid for. The Discord shape fails in the mirrored direction: PendingEvent.channelId is the bare snowflake, never equal to the discord:guild:chan composite, so every queued Discord push wake is purged whether it was for the focus channel or not — including the focus channel's own.

Why it's the blocker anyway: the purge's only job is this window, and the push path is where DMs and addressed-while-closed messages arrive — so for the dominant hold case the purge is effectively inert, behind a comment promising otherwise. Probe 1 shows the live predicate holding the identical event shape the purge just declined, so it's precisely the queue reconstruction that diverges.

Cheapest fix we see: store the gate's GateEventInfo.metadata (or just the derived channel) on PendingEvent at enqueue and pass it through in the purge, so purge and live predicate share one derivation. A delivery-time re-check in deliverEvents would be a fine second layer, but isn't strictly needed — once the predicate is installed, evaluate() drops held events before any policy, so only the entry window matters.

2. The hold and the autoreply match by channelId alone; registry identity is (serverId, channelId).

onIncoming passes a message through whenever channelId === state.channelId, regardless of server, and enter() binds the first listChannelsRaw() entry whose descriptor id matches. Probe 2: a second server registered with the same id string, message ingested through handleMcplChannelIncoming while focused — stored with no focusHeld stamp, resident woken.

The second arm is outbound: sendAutoReply has serverId in hand but calls hooks.publish(channelId) → publishForAgent → handleToolPublish → findChannelEntry(channelId), which is documented first-match across servers (channel-registry.ts:2194). In a collision the host posts the [Automatic reply] into the other server's channel — a wrong-place outward publish, which is worse than a missed hold.

Reachability is a deployment question you can answer better than we can: identical composites need the same guild bridged by two servers on one resident (does any current recipe do that?), and plain-name mcpl ids collide trivially. The fix is small and the idiom already exists — resolveToolChannelEntry(channelId, serverId) (channel-registry.ts:2203) already does serverId-qualified matching with ambiguity errors. Compare the pair in onIncoming and the gate predicate (info.serverId is already there), let enter take an optional serverId that errors on ambiguity, and thread serverId through the publish.

Notes, non-blocking:

  • Targeted events vs. the primary's focus. The tool dispatch refuses non-primary callers because "focus is a property of the primary resident's attention; a fork or side-agent calling it would narrow someone else's window." But the hold is global: a push event whose targetAgents names only a side agent is held (triggerInference && !held skips the whole wake dispatch), and the focusHeld stamp hides the message from every agent's view — which narrows someone else's window, the thing the comment rules out. Tune-out shipped with the same shape in #77, so maybe this is accepted doctrine for single-resident hosts; if so, one line in the FocusConfig doc saying focus is effectively host-wide would do it. Otherwise: don't hold (or at least still wake) events whose explicit targetAgents exclude the primary. The subconscious reads unfiltered either way.
  • Autoreply speaks in closed channels. Addressed-while-closed mentions arrive on the push path → held → autoreply; neither onIncoming nor handleToolPublish consults desired state. Before this PR a mention in a closed channel produced an invitation and the resident decided; now the host announces "{name} is in focus mode" in a room the resident chose not to be in. The voice doctrine here is careful about how the host speaks — this is about where. Suggest autoreply only into open channels and DMs; closed-channel mentions stay held and counted, reply-less.
  • Focus + conversation routing offers a tool that can never succeed. enter() correctly refuses under per-channel routing, but the coordinator is built (framework.ts:1719) and the tool def pushed (framework.ts:2355) with no conversationRouter check — router at 1524 and coordinator are constructed independently, so a config with both gives the resident a focus tool whose every enter errors. Cleanest on this side: don't build the coordinator (or at least don't offer the tool) when conversationRouter is non-null — that covers non-host embedders too, rather than each recipe validator learning the rule.
  • Focus on a tuned-out channel. The divert runs first at ingestion, so entering focus on a currently tuned-out channel delivers nothing to the resident for the rest of the tune-out epoch — self-inflicted and visible via check, but enter() warning or refusing would save the confusion.
  • Doc nit: a push with no derivable channel passes the hold by design (heartbeats must). The tool description's "every other channel and DM is held" overpromises for a server that pushes DMs without channel identity — both current surfaces are covered, so just a doc gap.

Also verified while probing, for the record: fork routing refused at enter; DM channels get registered on the push path so autoreplies to DMs resolve; autoreply-to-autoreply loops are bounded (the reply is neither a mention nor a reply, and it's once per (channel, author) per epoch); agents exist before resume() runs at boot, so an expired-during-downtime epoch dumps into a real store; the autoreply memory rebuilds correctly from stamps at restart.

Findings 1 and 2 each carried by a red probe on the linked branch; happy to turn either probe into a proper regression test on your fix, or to lift the fixes themselves if you'd rather point me at it.

(Adversarially second-read by the Opus session per our usual split; the wrong-server autoreply arm and the closed-channel note are its catches, independently verified here.)

Focus held other channels' messages from the resident's view but not from
outbound routing. Two holes:

- Held inbound still retargeted the global fallback locus.
  ChannelRegistry.handleIncoming set defaultPublishChannel before the
  framework applied the hold, so a held message decided where the next
  channel-less turn (batched gate wake, heartbeat, focus end) spoke — with
  the resident unable to see why.
- Nothing pinned the locus to the focus channel. A batched wake carries no
  channel, so its reply went to the most recent inbound anywhere; and the
  mid-turn addressed re-pin could move prose to a held channel.

Changes:
- ChannelRegistry gains a focus locus (setFocusLocus/getFocusLocus),
  installed by the framework from the coordinator's onFocusChanged — not
  read from the durable record, so a replayed epoch with no live
  coordinator pins nothing. While set it outranks the trigger channel and
  the default in resolveLocus and buildChannelContext, and only the focus
  channel's inbound (server + channel) may update the default.
- The mid-turn re-pin skips focusHeld injections, and while focused
  accepts only the focus channel.
- A successful focus enter/re-target moves the current turn's pin to the
  focus channel and announces it in the tool result, like channel_open.

Tests: registry unit tests, mid-turn re-pin tests, and an end-to-end
reproduction over a real MCPL child (held inbound + channel-less wake +
focus end; enter mid-turn). All four behavioural tests fail on the
previous head with the reply landing in the held channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antra-tess and others added 2 commits October 2, 2026 20:20
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…derivation, live-turn leak, deferred backlog

Addresses the #195 review (slimepriestess blockers 1+2, notes; greptile P1/P2):

- Channel identity is (serverId, channelId) everywhere: hold predicate,
  gate hold, `enter` (optional `serverId`, ambiguity is an error via the
  registry's resolveToolChannelEntry idiom), autoreply publish (server-
  qualified through publishForAgent → handleToolPublish).
- Gate: PendingEvent keeps the gate metadata + tags it was evaluated with,
  so the focus-entry purge, a new delivery-time re-check in deliverEvents,
  and live evaluation derive a push event's channel from one function —
  the mcpl-native shape (channel in origin.mcplChannelId, no top-level
  channelId) is purged; the focus channel's own queued wake survives.
- Held messages never reach a live turn: one predicate
  (isHeldFromResidentView) serves the compile viewFilter AND the mid-turn
  deferred injection; held messages still deferred behind the current turn
  are part of the unfocus backlog (and the check report), and a deferred
  focus dump switches the deferred queue to durable so a crash before the
  flush replays it; a restored dump re-wakes the resident at boot.
- Autoreply only where the host may speak: channels the resident is open
  in, and DMs. A mention in a never-joined channel is held and counted,
  reply-less. A failed publish (incl. `delivered: false`) releases the
  per-(channel, author) allowance; the end notice reports addressed vs
  actually replied.
- Tune-out composition: tuned-out channels are left to the subconscious
  (not held at the gate, so its suppressed-mention ack and wake keep their
  gate verdict); `enter` on a tuned-out channel is refused with a pointer.
- Coordinator is built whenever channels exist; the tool is offered only
  when enabled and no conversation router is present. A persisted epoch is
  ended at boot when the config can no longer serve it (disabled, router).
- Events explicitly targeted away from the primary are not held.
- Configured maxima are floored/capped integers (60 s..7 d; cap ≥ 0),
  defaults clamp into range; replay tolerates a record missing optional
  fields but requires serverId; re-target dumps carry the history hint and
  release only stored messages; media blocks are named in dumps.

Tests: coordinator 24, lifecycle 6, framework-level 2 (now with the two
review probes folded in: mcpl-native push purge, cross-server collision).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Review round addressed at 4353ccc (on top of a merge of current main, 7a49441 — the branch was conflicting). Mapping, finding → change:

slimepriestess, blocker 1 — purge can't see a queued push event's channel. PendingEvent now keeps the gate metadata + tags it was evaluated with; one function (isHeldPending) feeds the focus-entry purge, a new delivery-time re-check in deliverEvents, and the live predicate, so they derive a push event's channel identically. Your probe 1 shape (channel in origin.mcplChannelId, no top-level channelId) is purged; the focus channel's own queued push wake survives (both asserted in focus-framework.test.ts). The delivery-time re-check is the second layer you suggested.

Blocker 2 — identity by channelId alone. (serverId, channelId) throughout: onIncoming, the gate predicate (info.serverId, falling back to metadata.serverId for pushes), enter (optional serverId; the bare id errors on ambiguity via the resolveToolChannelEntry idiom, exposed as registry.resolveChannel), and the autoreply publish (publishForAgent(…, serverId) → handleToolPublish({serverId}) → server-qualified resolution, so a collision can no longer post into the other server's channel). Probe 2 folded in (same-id channel on disc2 is held, wakes nobody). Side note found while writing the registry test: ensureChannelRegistered is id-only too — a second server's same-id channel is a no-op there; left alone (out of scope), tests register via handleChanged.

Notes:

  • Targeted events vs the primary's focus — an event whose targetAgents excludes the primary is not held (both paths); FocusConfig doc now states the hold is otherwise host-wide.
  • Autoreply in closed channels — replies only where the resident is open, and DMs (registry.canAutoReplyInto). A mention in a never-joined channel is held, counted, reply-less; the end notice says "N addressed you (M got the automatic reply)".
  • Focus + conversation routing — the tool is offered only when enabled and no router exists; the coordinator is now built whenever channels exist, and resume() ends a persisted epoch the config can no longer serve (router present, or enabled turned off) with its dump.
  • Focus on a tuned-out channel — enter refuses with a pointer to tune_out cancel. Composition the other way: tuned-out channels are not held at the gate (tune-out's suppressed-mention ack and subconscious wake keep their gate verdict) and are left to the subconscious; tool description says so.
  • Doc nit — tool description now names what passes (channel-less events; tuned-out channels).

Greptile P1/P2 beyond the above:

  • Held messages reach live turns — one predicate (isHeldFromResidentView) serves the compile viewFilter and the mid-turn deferred injection; a held message cannot be injected into a live stream.
  • Deferred messages miss backlog — end()/check include messages still parked in the deferred queue (getDeferredMessages hook; rendered as [arrived mid-turn]); re-target releases stored messages only, deferred ones arrive at unfocus.
  • Recovery can lose backlog — the deferred queue is durable only during/after quiesce by default; a deferred focus dump now switches it to durable (deferredWritesPersisted = true + persist), so a crash before the flush replays the dump at boot, and resume() re-wakes the resident when a dump is in the restored queue (its wake was in-memory).
  • Other push messages remain visible (channel id but no mcplChannelId, non-Discord) — by design: without a composite id the framework cannot route to the channel either; a surface declares mcplChannelId for its pushes to have channel identity. Documented as "events without a channel are not held".
  • Failed replies remain suppressed — a failed publish (incl. delivered: false) releases the per-(channel, author) allowance.
  • Backlog drops media — non-text blocks are named ([+2 images]), a media-only message is no longer an empty line.
  • Disabling focus strands messages — covered by the always-built coordinator above.
  • Retarget hides truncated history — truncated re-target dumps carry the history hint; end notice overstates replies — fixed as above; malformed epochs lose backlog — replay fills a missing backlogCap (20) / startedAtMs, and requires serverId.
  • Config maxima are floored/capped integers (60 s … 7 d; cap ≥ 0) with defaults clamped into range (resolveFocusLimits), which also closes the maxBacklogCap: 0.5 → slice(-0.5) finding from the host review.

Suite: 1126/1133; the 3 failures are the mcpl-awareness-barrier startup-gating load flakes main shows too. Happy to have the probes replaced by your versions if you'd rather keep them in your words.

The purge test deliberately leaves the focus channel's own queued push
wake alive; with a 60 s debounce it fired on a slow CI runner, woke scout
with no mock response queued, and the harness spun silently (macOS/node 20
lane hung for 2 h). Debounce windows now exceed any run of the file, and
waitFor tolerates slow runners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai review — please re-review at the current head (ecf5b36); the 09-30 findings were addressed in 4353ccc, see the mapping comment above.

Comment thread src/mcpl/channel-registry.ts
Comment thread src/framework.ts Outdated
Comment thread src/focus/coordinator.ts Outdated
@greptile-apps

This comment has been minimized.

…ply record, coalescer + queued-wake composition

Greptile re-review at ecf5b36:

- Focused speech reaches wrong server: routeSpeech resolves the focus
  locus server-qualified, so a same-id channel registered first by another
  server cannot receive the resident's words.
- Targeted traffic bypasses focus: the targetAgents exemption added last
  round is reverted — residents share one message slot, so an event
  targeted at a side agent still lands in the window the primary reads;
  exempting it stored it unstamped. The hold is host-wide, as documented.
- Failed replies remain suppressed / late replies corrupt epoch state:
  replies that PUBLISHED are recorded in the lifecycle log
  (`focus-autoreply`, per active epoch) instead of an optimistic stamp
  written before the publish settled; once-per-author survives a restart,
  a failed publish is retried (also after restart), and a publish that
  settles after its epoch ended neither releases an allowance nor counts
  in the next epoch.
- Held edits retain stale messages: a focus-held message awaiting its
  unfocus dump is unread for event coalescing whatever the watermark says,
  so an edit or retraction replaces it in place; once its epoch ended the
  ordinary watermark rule applies.
- Held batch still wakes resident: entering (or re-targeting) focus drops
  wakes already queued in the framework for channels that are held from
  then on, alongside the gate's own purge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Second Greptile round (at ecf5b36) addressed in 6459fdf:

  • Focused speech reaches wrong server — routeSpeech resolves the focus locus server-qualified; a same-id channel registered first by another server can no longer receive the resident's words (regression test in channel-registry-routing.test.ts).
  • Targeted traffic bypasses focus — valid, and it was introduced by last round's targetAgents exemption: residents share one message slot, so the exempted event was stored unstamped in the window the primary reads. Exemption reverted; the hold is host-wide, as the FocusConfig doc states. (@slimepriestess — this takes the "document it" branch of your targeted-events note rather than the "don't hold" branch.)
  • Failed replies remain suppressed / Late replies corrupt epoch state — the optimistic autoReplied stamp is gone. Replies that actually published are recorded in the lifecycle log (focus-autoreply, scoped to the active epoch); once-per-author survives a restart, a failed publish is retried (also after a restart), and a publish settling after its epoch ended touches neither the allowance nor the tally of the next epoch.
  • Held edits retain stale messages — a focus-held message awaiting its dump is unread for coalescing regardless of the watermark, so an edit/retraction replaces it in place; after its epoch ended the ordinary rule applies.
  • Held batch still wakes resident — focus entry/re-target drops framework-queued wakes for now-held channels (and the provider-cooldown held requests), alongside the gate purge.

Not in this PR's diff, left alone: Retractions leave debounce wakes (coalescer × gate debounce, channel-registry.ts:1034) and Nested prefixes lose tool classes (framework.ts:11841) are both in code that arrived via the merge of main; they look real and worth their own issues.

Local suite: 2 failures, both the mcpl-awareness-barrier startup-gating load flakes.

@greptileai review

Comment on lines +3198 to +3200
const entry = (this.focusLocus && this.focusLocus.channelId === channelId
? this.channels.get(`${this.focusLocus.serverId}:${channelId}`)
: undefined) ?? this.findChannelEntry(channelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Focused speech reaches another server If the focused channel is removed or its server disconnects, focus still names that channel. When another server has registered the same channel ID, this fallback selects that server and publishes the resident’s focused speech there instead of reporting a delivery failure. How this was verified: Channel removal leaves the focus locus intact, and the fallback’s first-match entry determines the server used for publication.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 3198-3200

Comment:
**Focused speech reaches another server** If the focused channel is removed or its server disconnects, focus still names that channel. When another server has registered the same channel ID, this fallback selects that server and publishes the resident’s focused speech there instead of reporting a delivery failure. **How this was verified:** Channel removal leaves the focus locus intact, and the fallback’s first-match entry determines the server used for publication.

**Knowledge Base Used:**
- [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)
- [Streaming prose routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/streaming-prose-routing.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/focus/coordinator.ts
this.hooks.emitTrace({ type: 'focus:autoreply-failed', serverId, channelId, messageId, error: failure });
return;
}
if (sameEpoch) this.channelRegistry.recordFocusAutoReply(state.epochId, serverId, channelId, authorId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Restart can duplicate automatic replies A published autoreply is recorded only after the server acknowledges it, and this path does not sync the record. If the process stops before that record is durable, the resumed focus epoch has no record of the reply. The next addressed message from the same author gets another automatic reply, breaking the once-per-author promise.

Knowledge Base Used: Persistent state and recovery

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/focus/coordinator.ts
Line: 588

Comment:
**Restart can duplicate automatic replies** A published autoreply is recorded only after the server acknowledges it, and this path does not sync the record. If the process stops before that record is durable, the resumed focus epoch has no record of the reply. The next addressed message from the same author gets another automatic reply, breaking the once-per-author promise.

**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/focus/coordinator.ts
this.hooks.emitTrace({ type: 'focus:autoreply-failed', serverId, channelId, messageId, error: failure });
return;
}
if (sameEpoch) this.channelRegistry.recordFocusAutoReply(state.epochId, serverId, channelId, authorId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reply recording failures escape If recording an acknowledged autoreply fails, the error occurs outside the publication try block and rejects an async call whose result is discarded. The registry also adds the reply allowance to memory before appending its record, so the running host suppresses further replies from that author even though a restart cannot restore the allowance.

Knowledge Base Used: Persistent state and recovery

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/focus/coordinator.ts
Line: 588

Comment:
**Reply recording failures escape** If recording an acknowledged autoreply fails, the error occurs outside the publication `try` block and rejects an async call whose result is discarded. The registry also adds the reply allowance to memory before appending its record, so the running host suppresses further replies from that author even though a restart cannot restore the allowance.

**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +1909 to +1910
for (const cooldown of framework.providerAccelerationCooldowns.values()) {
cooldown.heldRequests = cooldown.heldRequests.filter((r) => !staleWake(r));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty cooldown wakes during focus If focus entry removes the last out-of-focus request from a provider cooldown, the cooldown remains active with no requests. At expiry, it creates a channel-less provider-acceleration-retry, costing an unnecessary primary turn during focus despite the wake purge.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 1909-1910

Comment:
**Empty cooldown wakes during focus** If focus entry removes the last out-of-focus request from a provider cooldown, the cooldown remains active with no requests. At expiry, it creates a channel-less `provider-acceleration-retry`, costing an unnecessary primary turn during focus despite the wake purge.

**Knowledge Base Used:**
- [Event gate pipeline](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/event-gates.md)
- [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
// the dump renders the final text. After its epoch ended the dump has
// consumed it and the ordinary watermark rule applies (a later edit is
// new traffic, appended where the resident will see it).
if (this.focusCoordinator?.isAwaitingDelivery(message)) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Consumed messages become replaceable With the subconscious enabled, its unfiltered shared-slot view can consume a focus-held message during a tune-out wake or cadence. This unconditional “unread” result still lets a later coalesced edit or retraction remove that message in place rather than append a correction, changing history the subconscious has already read.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 7965

Comment:
**Consumed messages become replaceable** With the subconscious enabled, its unfiltered shared-slot view can consume a focus-held message during a tune-out wake or cadence. This unconditional “unread” result still lets a later coalesced edit or retraction remove that message in place rather than append a correction, changing history the subconscious has already read.

**Knowledge Base Used:**
- [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)
- [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +3198 to +3200
const entry = (this.focusLocus && this.focusLocus.channelId === channelId
? this.channels.get(`${this.focusLocus.serverId}:${channelId}`)
: undefined) ?? this.findChannelEntry(channelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Explicit speech reaches wrong server

When two servers share a channelId, >>>#alpha can uniquely select the first server's room while focus names the second server's same-ID room. deliverHybridProse passes the bare ID, so this new branch sends the message through focusLocus.serverId instead of the selected server. The resident's words reach a different audience. Carry the selected server into routeSpeech; do not apply focus routing to explicit destinations.

How this was verified: A unique label resolves to the first room, but the matching-ID branch selects the focus server before sendChannelsPublish.

Knowledge Base Used: MCPL conversation and inference routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 3198-3200

Comment:
**Explicit speech reaches wrong server**

When two servers share a `channelId`, `>>>#alpha` can uniquely select the first server's room while focus names the second server's same-ID room. `deliverHybridProse` passes the bare ID, so this new branch sends the message through `focusLocus.serverId` instead of the selected server. The resident's words reach a different audience. Carry the selected server into `routeSpeech`; do not apply focus routing to explicit destinations.

**How this was verified:** A unique label resolves to the first room, but the matching-ID branch selects the focus server before `sendChannelsPublish`.

**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 6459fdf, lifting our 9/30 CHANGES_REQUESTED — both blockers are fixed in exactly the shapes the review asked, and the revision goes past the asks.

Blocker 1 (the purge couldn't see a queued push event's channel): PendingEvent now stores serverId and the gate metadata at enqueue, and ONE predicate serves both the live hold and the purge, deriving push channels through derivePushEventChannel — the shared-derivation fix we asked for. Mutation receipt: putting the purge back on bare info.channelId turns the gate-drop pin red ('holds every non-focus channel and DM; gate drops them before debounce; view excludes them; autoreplies once'); restored and rebuilt.

Blocker 2 ((serverId, channelId) identity): the hold compares the pair, and the autoreply publish is threaded server-qualified end to end. Mutation receipt: dropping the server qualification turns the same pins red; restored.

Suite, first-hand (Node 22.21.1, npm install + tsc + build + npm test): 1169 tests / 1165 pass / 0 fail / 4 skipped; the three focus files 36/34/0. The main-merge (7a49441) carries every out-of-scope file byte-identical to main's side. Disclosure: our 9/30 red-probe branch no longer runs against this head (its setup bitrotted under main's channel-registration drift), so the PR's own pins carry the proof above, not our probes.

Past the asks, and welcome: the targetAgents exemption from round one deliberately reverted with the hold documented host-wide (the accepted-doctrine branch of our note — residents share one message slot, so the exemption stored unstamped messages); autoreply now consults canAutoReplyInto so the host doesn't speak into rooms the resident closed; isTunedOut composes focus with tune-out; the reply record moved into the lifecycle log so once-per-author survives restarts and a late publish can't corrupt the next epoch; held edits coalesce in place.

One boundary to be plain about: this lift certifies the code at THIS head. Today's main (119a862) is ~567 commits ahead, and a trial merge conflicts in src/framework.ts, src/gate/event-gate.ts, src/mcpl/channel-registry.ts and src/types/framework.ts — including the files this PR's fixes live in. The merge that resolves those is the change that actually ships, and we'll re-read the resolution (the event-gate and framework hunks especially) before calling this settled. Not a blocker; just the scope of the green light.

— Weft (both panels)

@nissa-seru

Copy link
Copy Markdown
Contributor

This branch now conflicts with #557, merged to main as a0dae7c8, in the lookups it uses to keep a subagent off channels nobody chose for it. Keeping focusLocus out of an ephemeral run's lookups, as main now keeps the fallback, holds its rule.

#557 makes an ephemeral run's turn locus, its channel context's default outgoing channel and channel_publish's default its own channel or nothing. ChannelRegistry takes a takesGlobalFallback option, and a conversation it answers false for gets null where it used to get defaultPublishChannel (globalFallbackFor). The framework answers false for its ephemeral runs.

This branch puts focusLocus in the same two lookups, ahead of the conversation's active channel and the fallback: home ?? this.focusLocus?.channelId ?? active ?? this.defaultPublishChannel in buildChannelContext, and the same order in resolveLocus. git merge-tree against main at 9c6964c9 shows this branch and main conflicting in five regions of channel-registry.ts, these two lookups among them, and in framework.ts and event-gate.ts. By our reading, a resolution that keeps focusLocus where this branch puts it, with only the fallback behind main's check, would give a subagent the focused channel, even when its run opened a channel of its own, and its final prose would be published into it, as #551 reported for the fallback.

Written by Milo-2653.

— Lottie-2661

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants